commit 7d12ebef3d4cba701662839120b5ba56bae59dab Author: Aravind Date: Mon Aug 24 13:06:29 2026 +0530 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7082871 --- /dev/null +++ b/.env.example @@ -0,0 +1,59 @@ +# ============================================================================ +# Krow backend — example environment +# +# Copy to .env and fill in. .env is gitignored and must never be committed. +# Every value below is a placeholder or a safe local default: no real password, +# API key or token belongs in this file. +# +# cp .env.example .env +# ============================================================================ + +# ── Application ───────────────────────────────────────────────────────────── +APP_ENV=development # development | staging | production +LOG_LEVEL=info # debug | info | warn | error + +# ── HTTP server ───────────────────────────────────────────────────────────── +HTTP_HOST=127.0.0.1 +HTTP_PORT=8080 +HTTP_READ_TIMEOUT=15s +HTTP_WRITE_TIMEOUT=30s +HTTP_IDLE_TIMEOUT=60s +HTTP_SHUTDOWN_TIMEOUT=10s +# Browser origins allowed to call this API cross-origin, comma-separated. +# Unset in development it defaults to the Vite dev server on both hostnames +# (localhost and 127.0.0.1 are different origins to a browser) plus `vite +# preview`. Unset anywhere else it defaults to empty, meaning same-origin only. +# Origins are matched exactly, echoed back one at a time, and "*" is rejected. +# HTTP_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 + +# ── PostgreSQL ────────────────────────────────────────────────────────────── +# The local development database. DATABASE_NAME is mixed-case and hyphenated, +# so anything that interpolates it into SQL must quote it: "Krow-force". +DATABASE_HOST=127.0.0.1 +DATABASE_PORT=5432 +DATABASE_NAME=Krow-force +DATABASE_USER=postgres +DATABASE_PASSWORD= +DATABASE_SCHEMA=public + +# sslmode: disable is fine for a loopback dev database. APP_ENV=production +# rejects `disable` at startup — use require or verify-full there. +DATABASE_SSLMODE=disable + +# Pool and timeout tuning. +DATABASE_MAX_OPEN_CONNS=25 +DATABASE_MIN_IDLE_CONNS=2 +DATABASE_CONN_MAX_LIFETIME=30m +DATABASE_CONNECT_TIMEOUT=5s +DATABASE_STATEMENT_TIMEOUT=10s + +# ── Migrations ────────────────────────────────────────────────────────────── +# Consumed by the Makefile, which builds the golang-migrate URL from the +# DATABASE_* values above. Keep it pointed at the repository's migrations/. +MIGRATIONS_DIR=./migrations + +# ── Seed ──────────────────────────────────────────────────────────────────── +# The demo fixture, generated from the frontend repository's src/api/seed.js. +# The Makefile passes an absolute path; this default suits running from the +# repository root. +SEED_FIXTURE_PATH=./seed/fixtures/seed.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e1ceee --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Secrets and local configuration +.env +.env.local +.env.*.local + +# Go build output +/go-api/bin/ +*.test +*.out + +# Editor / OS +.DS_Store +.idea/ +.vscode/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..59dad1a --- /dev/null +++ b/Makefile @@ -0,0 +1,137 @@ +# ============================================================================ +# Krow backend — developer tasks +# +# Migration commands are thin wrappers over the golang-migrate CLI. The +# migration FILES are the source of truth for the schema; nothing here or in +# go-api/ ever issues DDL of its own. +# +# brew install golang-migrate # or see github.com/golang-migrate/migrate +# ============================================================================ + +SHELL := /bin/bash +.DEFAULT_GOAL := help + +# Capture APP_ENV as the process environment supplied it, BEFORE .env is +# included. Make gives an included assignment precedence over the environment, +# so without this `APP_ENV=production make migrate-down` would silently read +# `development` out of .env and the guard below would not fire. +APP_ENV_FROM_ENVIRONMENT := $(APP_ENV) + +ifneq (,$(wildcard .env)) +include .env +export +endif + +# The environment wins; .env is only the fallback. A command-line override +# (`make migrate-down APP_ENV=production`) outranks both, as Make intends. +ifneq (,$(APP_ENV_FROM_ENVIRONMENT)) +APP_ENV := $(APP_ENV_FROM_ENVIRONMENT) +endif + +MIGRATIONS_DIR ?= ./migrations +DATABASE_SCHEMA ?= public +DATABASE_SSLMODE ?= disable + +# URL-encode the credentials and the database name. "Krow-force" is mixed-case +# and hyphenated, and a password can contain anything; neither is safe raw. +enc = $(shell printf '%s' '$(1)' | python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.stdin.read(), safe=""))') + +DB_URL = postgres://$(call enc,$(DATABASE_USER)):$(call enc,$(DATABASE_PASSWORD))@$(DATABASE_HOST):$(DATABASE_PORT)/$(call enc,$(DATABASE_NAME))?sslmode=$(DATABASE_SSLMODE)&search_path=$(DATABASE_SCHEMA) +MIGRATE = migrate -path $(MIGRATIONS_DIR) -database "$(DB_URL)" + +PSQL = psql -h $(DATABASE_HOST) -p $(DATABASE_PORT) -U $(DATABASE_USER) -d "$(DATABASE_NAME)" + +.PHONY: help +help: ## Show this help + @grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' + +# ── Go ────────────────────────────────────────────────────────────────────── + +.PHONY: run +run: ## Run the API against the local database + cd go-api && go run ./cmd/api + +.PHONY: build +build: ## Compile the API to go-api/bin/api + cd go-api && go build -o bin/api ./cmd/api + +.PHONY: tidy +tidy: ## go mod tidy + cd go-api && go mod tidy + +.PHONY: fmt +fmt: ## gofmt the module + cd go-api && gofmt -w ./cmd ./internal + +.PHONY: vet +vet: ## go vet the module + cd go-api && go vet ./... + +.PHONY: test +test: ## Run the Go tests + cd go-api && go test ./... + +.PHONY: check +check: fmt vet test ## Format, vet and test + +# ── Database ──────────────────────────────────────────────────────────────── + +.PHONY: db-create +db-create: ## Create the database if it does not exist (never drops anything) + @if psql -h $(DATABASE_HOST) -p $(DATABASE_PORT) -U $(DATABASE_USER) -d postgres -tAc \ + "SELECT 1 FROM pg_database WHERE datname = '$(DATABASE_NAME)'" | grep -q 1; then \ + echo "database \"$(DATABASE_NAME)\" already exists — nothing to do"; \ + else \ + psql -h $(DATABASE_HOST) -p $(DATABASE_PORT) -U $(DATABASE_USER) -d postgres -c \ + 'CREATE DATABASE "$(DATABASE_NAME)"' && echo "created \"$(DATABASE_NAME)\""; \ + fi + +.PHONY: seed +seed: ## Load the frontend demo dataset (idempotent upsert in one transaction) + cd go-api && SEED_FIXTURE_PATH=$(CURDIR)/seed/fixtures/seed.json go run ./cmd/seed + +.PHONY: gen-resources +gen-resources: ## Regenerate domain descriptors from the live schema + python3 scripts/gen_resources.py > go-api/internal/domain/resources_gen.go + cd go-api && gofmt -w ./internal/domain + +.PHONY: db-health +db-health: ## Ask the running API for its database health + @curl -fsS http://$(HTTP_HOST):$(HTTP_PORT)/health | python3 -m json.tool + +# ── Migrations ────────────────────────────────────────────────────────────── + +.PHONY: migrate-up +migrate-up: ## Apply all pending migrations + $(MIGRATE) up + +.PHONY: migrate-down +migrate-down: ## Roll back exactly one migration (guarded outside development) + @if [ "$(APP_ENV)" != "development" ]; then \ + echo "refusing: migrate-down is only permitted when APP_ENV=development (got '$(APP_ENV)')"; \ + echo "roll back staging/production deliberately, with a reviewed plan and a fresh backup."; \ + exit 1; \ + fi + $(MIGRATE) down 1 + +.PHONY: migrate-status +migrate-status: ## Show the currently applied migration version + @$(MIGRATE) version + +.PHONY: migrate-new +migrate-new: ## Create a new migration pair: make migrate-new NAME=add_widgets + @test -n "$(NAME)" || { echo "usage: make migrate-new NAME=add_widgets"; exit 1; } + migrate create -ext sql -dir $(MIGRATIONS_DIR) -seq $(NAME) + +.PHONY: migrate-force +migrate-force: ## Clear a dirty state: make migrate-force VERSION=1 + @test -n "$(VERSION)" || { echo "usage: make migrate-force VERSION=1"; exit 1; } + $(MIGRATE) force $(VERSION) + +# There is deliberately no `migrate-drop` target. golang-migrate's `drop` +# command deletes every table in the database; it must never be one keystroke +# away from a developer who meant `down`. Run it by hand if you truly need it. + +.PHONY: verify-schema +verify-schema: ## Print the tables, enums, FKs and indexes that exist in the target schema + @$(PSQL) -f scripts/verify_schema.sql diff --git a/README.md b/README.md new file mode 100644 index 0000000..db8c92c --- /dev/null +++ b/README.md @@ -0,0 +1,231 @@ +# krow-backend + +The backend for Krow — a Go API over PostgreSQL, with a Python Owliver service +to follow. The Krow frontend lives in a **separate repository** (`krow-demo`) +and is not vendored, copied or modified here. + +**Status: Phase 3C — session authentication.** The API serves the endpoints in +`docs/api-contract.md` against PostgreSQL, loaded with the frontend's own demo +dataset. Every endpoint except `GET /health` and the two `/api/v1/auth/*` routes +requires a session: sign in with a password, hold an HttpOnly cookie, and the +server resolves it to a real user on every request. Authorization — which roles +may do what — is Phase 3D and is not implemented. No Owliver, no RAG, no Redis, +NATS or S3. + +## Layout + +``` +krow-backend/ +├── go-api/ +│ ├── cmd/ +│ │ ├── api/ the HTTP service entrypoint +│ │ └── seed/ loads the demo dataset +│ ├── internal/ +│ │ ├── config/ environment loading + validation +│ │ ├── db/ pgx pool and the database health check +│ │ ├── domain/ resource descriptors (generated from the schema) +│ │ ├── repo/ pgx access layer — every statement built here +│ │ ├── service/ validation, org scoping, contract semantics +│ │ ├── orgctx/ the organization a request runs as +│ │ ├── httpserver/ router, handlers, /health, graceful shutdown +│ │ ├── seeder/ fixture loading + the attendanceSeed.js port +│ │ └── testutil/ disposable migrated + seeded test database +│ └── go.mod +├── migrations/ SQL migrations — the source of truth for the schema +├── seed/fixtures/ seed.json, generated from the frontend repository +├── docs/api-contract.md the frozen client contract +├── infrastructure/ deployment definitions (empty until later) +├── scripts/ verify_schema.sql, gen_resources.py +├── Makefile developer, migration and seed tasks +└── .env.example +``` + +## Architecture + +``` +HTTP handler → service → repository → pgx → PostgreSQL +``` + +No ORM. Every statement is built in `internal/repo` from a `*domain.Resource`: +column lists are explicit, every value is a bind parameter cast to its declared +type, and no identifier ever comes from user input — a filter or sort name is +resolved to a real column before any SQL is assembled. + +The resource descriptors in `internal/domain/resources_gen.go` are **generated +from the live schema** (`make gen-resources`), so column names, types, enum +values and nullability cannot drift from the migrations. The hand-maintained +part is the per-resource metadata — path, default sort, default limit, supported +operations, required fields — which comes from `docs/api-contract.md`. + +One shared query builder rather than fourteen repositories is deliberate: the +contract's awkward semantics (`NULLS LAST` in both directions, the `, id` +tiebreaker, per-endpoint limits, shallow PATCH, idempotent DELETE) are then +implemented once and apply identically everywhere. + +## Requirements + +| Tool | Version used | Install | +| --- | --- | --- | +| Go | 1.27 | `brew install go` | +| golang-migrate | 4.19.1 | `brew install golang-migrate` | +| PostgreSQL | 18.6 | `brew install postgresql@18` | + +## Getting started + +```bash +cp .env.example .env # then fill in DATABASE_USER / DATABASE_PASSWORD +make db-create # creates the database if it does not exist +make migrate-up # applies every migration in migrations/ +make seed # loads the frontend's demo dataset +make run # http://127.0.0.1:8080/health +``` + +## The API + +38 endpoints, specified in `docs/api-contract.md`. Every one exists because a +frontend call site exists — a table in the database is never a reason for an +endpoint. `DELETE /job-postings/{id}` and `POST /shift-records` return 405 +because nothing in the frontend deletes a posting or creates a shift record. + +```bash +curl localhost:8080/api/v1/job-postings +curl "localhost:8080/api/v1/job-applications?job_posting_id=&limit=50" +curl "localhost:8080/api/v1/job-applications?status=hired&status=interview" +curl localhost:8080/api/v1/me +``` + +**Authentication is required.** `POST /api/v1/auth/login` verifies a password +with argon2id and issues a server-side session; the raw token goes out in an +HttpOnly, SameSite=Lax cookie and only its SHA-256 is stored. Middleware resolves +that cookie to a user on every request and puts the user's organization on the +context — the same seam `devOrgMiddleware` used to occupy, so nothing in the +service or repository layers changed. + +Set a password before signing in: the seeded user's `password_hash` is NULL until +`go run ./cmd/setpassword -email demo@krow.app` is run. + + curl -c jar -X POST localhost:8080/api/v1/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"demo@krow.app","password":"…","remember_me":false}' + curl -b jar localhost:8080/api/v1/me + curl -b jar -X POST localhost:8080/api/v1/auth/logout + +Authorization is **not** implemented. `users.role` is carried on the identity and +consulted nowhere: any signed-in user reaches every endpoint. That is Phase 3D. + +## Seeding + +`make seed` loads `seed/fixtures/seed.json`, which is generated by executing the +frontend's `src/api/seed.js` through Vite — so ids, dates, numbers and enum +values arrive exactly as the demo has them, with no transcription step. + +Shift records are the exception: they are **generated** by a Go port of +`attendanceSeed.js` rather than snapshotted, because their dates are anchored to +*now*. `dataResolver.inPeriod` windows every collection on `created_date`, so a +frozen snapshot would read as permanently empty a fortnight later. + +**Idempotency: explicit upsert inside one transaction.** Every record's key is +derived deterministically from its source id (uuid v5), so re-running targets the +same rows and restores them to their seeded values. Nothing is deleted, so +records created through the API survive a re-seed. + +## Tests + +```bash +make test # go test ./... +``` + +55 tests. The database-backed ones build a disposable database — dropped, +recreated, migrated and seeded per run, named `krow_backend_autotest_` so +concurrent test packages cannot collide. They skip rather than fail when +PostgreSQL is unreachable. + +Seed assertions compare the database against the fixture field-by-field rather +than against numbers typed into a test. The two ordering guarantees +(`NULLS LAST` in both directions, and the `, id` tiebreaker) are covered by tests +verified to fail when the guarantee is removed. + +`GET /health` is public and says only whether traffic should be sent here: +`{"status": "ok"}` with `200`, `{"status": "degraded"}` with `200` when the +database is up but unmigrated or left dirty, and `{"status": "unavailable"}` +with `503` when it is unreachable. Nothing about the server, the database or +the schema appears in the body — an unauthenticated caller gets the verdict, +not the reasoning. + +The check itself is unchanged: `db.Check` still gathers the PostgreSQL version, +the database and schema names, the applied migration version, the table count +and the connection error, and the handler logs all of it. Read it from the +server log (`debug` when healthy, `warn` otherwise), or from psql. + +## Migrations + +Migration files are the source of truth for the schema. Nothing in `go-api/` +issues DDL, no ORM generates it, and no table is ever created by hand — if the +database and `migrations/` disagree, `migrations/` is right and the database is +wrong. + +```bash +make migrate-up # apply everything pending +make migrate-status # current version +make migrate-down # roll back exactly one step +make migrate-new NAME=add_sessions # scaffold the next pair +make verify-schema # print tables, enums, FKs, indexes +``` + +**Ordering.** `-seq` numbering: `000001`, `000002`, … golang-migrate applies +them in ascending order and records the highest applied version in +`schema_migrations`. Two developers who both branch off `000001` and both write +`000002` get a collision at merge — rename the later one rather than resolving +it in the database. + +**Never edit an applied migration.** Once a migration has run anywhere other +than your own machine, it is immutable. Change it and every database that +already applied it silently diverges from every one that has not. Write +`000002` instead. + +**Guards against destructive change:** + +- There is no `make migrate-drop`. golang-migrate's `drop` command deletes every + table in the database; it is deliberately not one keystroke from `down`. +- `make migrate-down` refuses to run unless `APP_ENV=development`. +- Down migrations name every object they drop. No `DROP SCHEMA`, no + `DROP DATABASE`, no `CASCADE` on a schema. +- `config.validate()` rejects `DATABASE_SCHEMA=pg_catalog`, `pg_toast`, + `information_schema` or anything starting with `pg_`, so the application can + never be pointed at a system schema. +- `DATABASE_SSLMODE=disable` is rejected when `APP_ENV=production`. + +**Dirty state.** If a migration fails partway, golang-migrate marks the version +dirty and refuses to continue. Fix the SQL, restore from backup if the failure +left data behind, then `make migrate-force VERSION=` and re-run. +`/health` answers `"degraded"` while the version is dirty, and the handler logs +`migration_dirty` alongside the applied version, so the detail is in the server +log rather than in the public response. + +**Staging and production.** The same files, run by CI against the target +database as a discrete deploy step *before* the new binary rolls out — which +means every migration must be backwards-compatible with the currently-running +version. Expand, migrate, contract: add a nullable column, backfill, switch +reads, drop the old column in a later release. Never in one migration. + +## Schema + +`migrations/000001_initial_schema.up.sql` creates 17 tables in `public`. Fifteen +correspond to entities the frontend actually reads or writes through +`src/api/base44Client.js`; `organizations` and `user_preferences` are the two +additions, and both are documented in the migration itself. + +Conventions: + +- `uuid` primary keys, with a nullable unique `legacy_id text` so the existing + seeded record ids survive a data migration. +- `timestamptz` throughout. +- `created_date` / `updated_date` keep their frontend names deliberately. The + frontend windows every collection on `created_date` and its default sort + string is `-created_date`; renaming these to `created_at` would mean touching + the resolver, the store's sort parser and every hook. +- Native enums for closed vocabularies, `text` + `CHECK` where the set is still + moving. +- Email columns are `citext` and stay populated even where a uuid FK also + exists — the frontend joins workers by email today, and those joins must keep + working. diff --git a/docs/KROW_BACKEND_COMPLETE_SUMMARY.md b/docs/KROW_BACKEND_COMPLETE_SUMMARY.md new file mode 100644 index 0000000..2307d29 --- /dev/null +++ b/docs/KROW_BACKEND_COMPLETE_SUMMARY.md @@ -0,0 +1,2411 @@ +# Krow Backend — Complete Technical Summary + +**Audience:** backend, frontend, AI/agent and DevOps engineers, and engineering leads. +**Scope:** what exists in the `krow-backend` repository today, how it got here, and where it is heading. + +**How this document was produced.** Every factual claim below was read out of the +repository at `/Users/apple/Krow/krow-backend` as it currently stands — Go source, +`go.mod`, migrations, `Makefile`, `README.md`, `docs/api-contract.md`, tests and +fixtures — plus read-only queries against the local development database. Where an +earlier report or an in-repository comment disagrees with the code, **the code is +treated as authoritative** and the disagreement is recorded in §17. + +Anything that could not be checked against this repository is marked +**"Not verified from current repository."** Nothing here is inferred from a system +that is not present in this checkout. + +This is a technical history and current-state document. It does not certify phases. + +--- + +## 1. Executive Summary + +### What the Krow backend is + +Krow is a hiring and workforce platform: job postings, applications, AI screening +interviews, staff records, worker profiles, assignments, attendance, a training +library, evidence of work, and an audit log. On top of that sits an authoring +system for **Agents** and **Skills** — Markdown documents with YAML frontmatter that +describe what the product's assistant can be offered on each page. + +`krow-backend` is the Go HTTP API and PostgreSQL database behind that product. The +Krow frontend lives in a **separate repository** (`krow-demo`) and is not vendored, +copied or modified here. + +### Why the backend was introduced + +The frontend shipped first, as a demo. Its data layer was a client-side store: +`src/api/store.js` held arrays in memory, `src/api/seed.js` filled them at boot, and +`src/api/base44Client.js` was the seam every hook called through. That arrangement +has three properties that stop being acceptable the moment more than one person uses +the product: + +- **Data does not survive.** It lives in the browser tab. +- **There is no tenancy and no identity.** Every viewer sees the same array. +- **Nothing is enforceable.** Any rule the client does not apply does not exist. + +The backend exists to move persistence, identity, tenancy and enforcement to a +server, **without rewriting the frontend above the transport seam**. That constraint +is written into `docs/api-contract.md` as its acceptance criterion: + +> Replacing the transport inside `src/api/base44Client.js` — and changing no other +> frontend file — must leave the application behaving identically. + +Every design decision downstream follows from that sentence. + +### The transformation + +``` +OLD CURRENT + +React React + | | +base44Client base44Client + | | +localStorage / mock data HTTP + | + Go API + | + PostgreSQL +``` + +The shape above the seam is unchanged. What moved is everything below it: the +records now live in PostgreSQL, the organization a request runs as comes from a +server-side session rather than from nowhere, and the rules about who may read or +write what are applied in SQL and in the handler rather than in the browser. + +### The Agent / Skill path + +Agents and Skills are authored as Markdown. The backend parses them, validates them +against the same rules the frontend editor applies, projects a few queryable columns +out of them, and stores the Markdown itself verbatim: + +``` +Markdown + | +Parser (internal/definition — YAML subset + frontmatter, ported from JS) + | +Validator (ValidateAgent / ValidateSkill — frontend messages, plus DB bounds) + | +PostgreSQL (agent_definitions / skill_definitions; markdown stored byte-for-byte) + | +CRUD API (/api/v1/agent-definitions, /api/v1/skill-definitions) + | +Runtime Loader (internal/runtime — loads by id or definition_id, re-parses) + | +Dependency Resolution (agent's `skills:` list resolved within tenant + ownership scope) + | +Execution Boundary (AgentExecutor / SkillExecutor interfaces) +``` + +### What the runtime can and cannot do today + +**It can:** load an authored agent or skill from the database by UUID or by +`definition_id`; re-parse and re-validate its Markdown; refuse it if its status makes +it ineligible (a draft or archived agent, an inactive skill); resolve an agent's +declared skill dependencies within the caller's organization and ownership scope, +preferring a personal definition over an organization one with the same +`definition_id`; deduplicate the resolved list; and hand the resolved agent to an +executor through a typed interface. + +**It cannot:** execute anything. The only executor implementation in the repository +is `UnavailableExecutor`, which returns `ErrExecutorUnavailable` for both agents and +skills. There is no LLM client, no prompt assembly, no tool dispatch and no external +AI call anywhere in `go-api/`. + +**It is also not reachable over HTTP.** No route in `internal/httpserver` references +the `runtime` package; a repository-wide search for `runtime.` outside the package +itself and its tests returns nothing. The runtime is an internal boundary exercised +only by its own test suite. + +### Current architectural direction + +The backend is a layered, no-ORM Go service where the shape of every resource is +**generated from the live database schema** and the rules about every resource are +**hand-written next to it**. Authorization is deny-by-default. Tenancy and ownership +are SQL predicates rather than post-fetch filters. The definition system treats the +authored Markdown as the authoritative artefact and every column derived from it as +a cache. The runtime establishes where AI execution will attach, without attaching +it. + +--- + +## 2. Development History + +The repository documents its own work in phases, in migration headers, package +documentation and `docs/api-contract.md`. What follows describes what each phase +introduced and what of it is in the code today. + +### Phase 1 — Backend Foundation + +**What was built.** The Go module, the PostgreSQL connection, the migration +discipline and the initial schema. + +- **Go module** — `github.com/krow/krow-backend/go-api`, `go 1.27`. Three direct + dependencies and nothing else: `github.com/jackc/pgx/v5`, `golang.org/x/crypto`, + `golang.org/x/term`. No web framework, no ORM, no YAML library, no test framework. +- **Database access** — `internal/db` owns a `pgxpool.Pool`. `db.Open` deliberately + acquires and pings once at startup, because `pgxpool.New` is lazy and would + otherwise defer a misconfiguration to the first request. +- **Configuration** — `internal/config` loads from the environment with typed + defaults and a `validate()` pass that refuses: an unknown `APP_ENV`; a port out of + range; a `DATABASE_SCHEMA` that is a PostgreSQL system schema or starts with + `pg_`; `MIN_IDLE_CONNS` above `MAX_OPEN_CONNS`; `DATABASE_SSLMODE=disable` when + `APP_ENV=production`; and a CORS entry of `*` or one without a scheme. +- **Health** — `GET /health`, and `db.Check` behind it. +- **Migrations** — `migrations/` is the source of truth for the schema. Nothing in + `go-api/` issues DDL, and no ORM generates any. The `Makefile` wraps the + `golang-migrate` CLI (README records 4.19.1). + +**Implementation decisions worth carrying forward.** + +- `uuid` primary keys plus a nullable unique `legacy_id text`, so the frontend's + existing string ids (`jobposting_m1a2b3c001`) survive as data without becoming the + addressable id. +- `timestamptz` throughout; `created_date` / `updated_date` keep the frontend's own + names, because the frontend windows every collection on `created_date` and its + default sort string is `-created_date`. +- Native enums for closed vocabularies, `text` + `CHECK` where the set is still + moving. +- Email columns are `citext`, and stay populated even where a UUID foreign key also + exists, because the frontend joins workers by email today. +- Guards against destructive change: there is deliberately **no** `make migrate-drop` + target; `make migrate-down` refuses unless `APP_ENV=development`; down migrations + name every object they drop, with no `DROP SCHEMA` and no `CASCADE` on a schema. + +### Phase 2A — Architecture Decisions + +Four questions were raised, investigated against the frontend, and recorded in +`docs/api-contract.md` §11 as deferred with reasons. All four are still recorded +there as deferred. + +| Ref | Question | Recorded outcome | +| --- | --- | --- | +| **D2** | Is `company` a real entity? | It is free text on `job_postings`, displayed and read through a fallback chain, but never grouped by id, joined, or given a route. Kept as `company text NOT NULL DEFAULT ''`. No `clients` table, no endpoint. | +| **D3** | Are `assigned` / `rejected` pipeline stages? | The frontend's `STAGE_ORDER` excludes both from every funnel count via `indexOf(...) >= from`. The enum carries all seven values; the API stores and returns `status` verbatim and never filters, reinterprets or normalises it. The funnel exclusion is recorded as a live frontend behaviour that Phase 2 does not touch. | +| **D6** | Do the unmounted pages come back? | Fourteen page files plus a layout are imported by `App.jsx` and mounted on no route. Three operations are reachable only through them. All three endpoints were included anyway and marked *unreachable-today*, because the calling code exists and excluding them would make the transport shim need conditional methods. | +| **U1** | Where do shift records come from? | `ShiftRecord` has exactly one frontend seam operation: `.list('-created_date', 500)`. No create, no update, no delete. The contract specifies `GET /api/v1/shift-records` only — no `POST` — because there is no call site to derive a write contract from. | + +**Organization / tenant modelling.** `organizations` and `user_preferences` are the +two tables in migration 000001 that do not correspond to an entity the frontend +reads through `base44Client.js`; both are documented in the migration itself. Every +tenant-scoped table carries `org_id`. Two tables — `courses` and `learning_paths` — +are *organization-nullable*: a `NULL org_id` means the shared platform library, +visible to every tenant. + +### Phase 2B — API Contract + +`docs/api-contract.md` (1,060 lines) was written before implementation, derived +entirely from the frontend repository and the Phase 1 schema. Its stated rule: every +endpoint exists because a call site exists, and every semantic was read out of +`src/api/store.js` rather than designed. + +- **Operation matrix.** An operation with no call site gets no endpoint. + `DELETE /job-postings/{id}` and `POST /shift-records` are absent for that reason, + and the router answers 405 for them because the path pattern exists under another + method. +- **REST shape.** Base path `/api/v1`; kebab-case plural resource names, with mass + nouns staying singular (`/staff`, `/evidence`, `/user-activity`); `PATCH` with + shallow merge and no `PUT`. +- **Field naming.** snake_case, identical to the frontend's own field names — no + renaming, no camelCase conversion. The single documented exception is + `/me/preferences`. +- **Envelope.** Responses are wrapped in `{"data": …}`, with `meta` on collections. + The frontend's methods return bare values; the shim unwraps with one `.data`. What + the envelope buys is `meta.total`, which the bare shape has nowhere to put. +- **Filtering (§6).** Two operators only: equality for a single value, membership + for a repeated parameter. Every non-reserved query parameter is a field filter. +- **Sorting (§7).** `NULLS LAST` in **both** directions, because the frontend's + comparator returns before it can place a null; and `, id` appended to every + `ORDER BY`, because JavaScript's sort is not stable in the way the UI relies on. +- **Pagination (§8).** `limit` / `offset`, with per-endpoint defaults taken from the + literal arguments at each frontend call site. +- **Errors (§5).** A single envelope shape with a code, a message and a details map. + +### Phase 2C — Backend Implementation + +**Layers.** `internal/domain` (descriptors and errors) → `internal/service` +(parsing, validation, contract semantics) → `internal/repo` (all SQL) → pgx. + +**Generated descriptors.** `internal/domain/resources_gen.go` is produced by +`scripts/gen_resources.py` (`make gen-resources`), which reads column names, types, +enum values and nullability out of `information_schema`. The hand-maintained part is +the per-resource metadata — path, default sort, default limit, supported operations, +required fields — which comes from the contract. Column definitions therefore cannot +drift from the migrations. + +**One query builder, not fourteen repositories.** `internal/repo/repo.go` builds +every statement from a `*domain.Resource`: column lists are explicit, every value is +a bind parameter cast to its declared type, and no identifier ever comes from user +input — a filter or sort name is resolved to a real `*domain.Column` before any SQL +is assembled. The contract's awkward semantics are then implemented once and apply +identically everywhere. + +**Seed system.** `seed/fixtures/seed.json` is generated by executing the frontend's +`src/api/seed.js` through Vite and serialising what it exports, so ids, dates, +numbers and enum values arrive exactly as the demo has them with no transcription +step. `internal/seeder` loads it inside a single transaction with explicit upserts. +Shift records are the exception and are generated by a Go port of +`attendanceSeed.js` — see §6. + +**Bugs and gaps this phase exposed**, all recorded in `docs/api-contract.md` §13: + +- **Three columns had no home** (§13.1). `job_applications.interview_id`, + `courses.training_outline` and `worker_profiles.score_breakdown` are written or + read by the frontend and were absent from migration 000001. Migration 000001 was + not edited; the columns were added in **000002**. `interview_id` is deliberately + *not* a foreign key, because a seeded application references an interview id for + which no record exists, and nulling it would have transformed source data. +- **A constraint that rejected real data** (§13.2). `job_applications_screened_consistent` + from 000001 rejected 9 of 24 seeded applications — precisely the 9 AI-scored ones. + `screened_at` is never read or written by the frontend; the constraint came from a + blueprint rather than from evidence. Dropped in **000003**; the column is retained, + nullable and unused. All 53 CHECK constraints were then evaluated against all 245 + seeded records; the other 52 hold. +- **Unknown fields are rejected, not ignored** (§13.5). A body field that maps to no + column answers **422** with the field named in `details`. The reasoning is + explicit: silently ignoring them is exactly how the three columns above would have + been lost. Server-owned fields (`id`, `org_id`, `created_date`, `updated_date`, + `legacy_id`) remain ignored rather than rejected. +- **Three deliberate divergences from `store.js`** (§13.4): email matching is + case-insensitive because the columns are `citext`; `updated_date` is always + populated because the column is `NOT NULL` and the seeder sets it to + `created_date`; and `_order`, a positional index the frontend used while building + its course list, is dropped. + +### Phase 2D — Frontend Transport Integration + +This phase belongs to the frontend repository, which is not present in this +checkout. What **is** verifiable here is the backend side of the seam: + +- **CORS** (`internal/httpserver/cors.go`). Origins are matched exactly against an + allowlist and echoed back one at a time; `*` is rejected at config validation. A + request whose `Origin` is not on the list is served normally with no CORS headers — + the API does not refuse it, the browser simply will not hand the response to the + page. That distinction is deliberate so that curl, health checkers and + server-to-server callers, which send no `Origin`, are unaffected. With an empty + allowlist the middleware is not installed at all. +- **Development defaults.** With `APP_ENV=development` and `HTTP_CORS_ORIGINS` + unset, the allowlist defaults to `http://localhost:5173`, `http://127.0.0.1:5173` + and the `vite preview` port — both hostnames, because a browser treats them as + different origins. Unset anywhere else it defaults to empty, meaning same-origin + only. +- **JSON everywhere.** `net/http`'s own plain-text 404 and 405 replies are + intercepted and rewritten into the error envelope, so every response from the API + is JSON. + +The frontend files named in the contract — `base44Client.js`, `krowHooks.js`, +`store.js` — appear only as references inside this repository's documentation and +comments. `httpClient.js` and any `VITE_API_*` environment variable are **not +referenced anywhere in this repository**: *Not verified from current repository.* + +**Known frontend/backend mismatches** recorded in the contract §12: multi-record +writes are not transactional; collection caps truncate silently; all aggregation is +client-side; search is browser-side substring matching; `DELETE` on a missing record +returns 200. See §17. + +### Phase 3 — Authentication & Authorization + +Split across several steps in the repository's own narrative; what exists today is +described in §8. Chronologically: + +**3B — schema and primitives.** Migration **000004** adds two facts and nothing +else: a globally unique index on `users.email` (because the login form supplies no +organization, so an email must resolve to exactly one user), and a `sessions` table. +The migration explicitly declines to add roles, permissions, organization_members, +credentials or refresh_tokens tables. `internal/auth` was written in the same step +and deliberately knows nothing about HTTP: password hashing (`password.go`), token +generation and hashing (`token.go`), the session lifecycle (`session.go`), the +PostgreSQL stores (`store.go`, `users.go`) and credential verification +(`credentials.go`). + +**3C — the HTTP surface.** `POST /api/v1/auth/login`, `POST /api/v1/auth/logout`, +and the `authenticate` middleware. This step removed `devOrgMiddleware`, which had +put a fixed organization on every request with no credential behind it. Because the +organization had always been threaded explicitly through the service and repository +boundaries rather than defaulted inside the SQL, that swap changed one line and +nothing below it. + +**3D — authorization.** `internal/domain/policy.go` — a hand-written table, kept +separate from the generated descriptors precisely so that regenerating descriptors +can never silently drop an access rule, and a new column can never grant anyone +anything by accident. The handler gate is `Server.authorize` in +`internal/httpserver/api.go`; the row predicates are in `builder.ownership` in +`internal/repo/repo.go`. `docs/api-contract.md` §9A documents the resulting contract. + +**Health information exposure reduction.** `GET /health` is public, so its body was +reduced to a single field: `{"status": "ok" | "degraded" | "unavailable"}`. The +PostgreSQL version, database name, schema name, applied migration version, table +count, connection error text, environment and uptime were removed from the response +and moved to the server log — `debug` when healthy, `warn` otherwise. `db.Check` +itself is unchanged and still gathers all of it. + +**Future authentication direction** is discussed in §18 and is separated there from +what the code does. + +### Phase 4A — Agent & Skill Architecture + +Agents and Skills are configuration written as Markdown with YAML frontmatter. Three +tiers exist, and the reasoning for keeping them apart is recorded in migration +000005: + +| Tier | Where it lives | Rows in this database | +| --- | --- | --- | +| **shipped** | `src/agents/**/*.md`, `src/skills/**/*.md` in the frontend repo, versioned in Git, bundled at build time | none — they are code, and putting them in a table would trade `git log`, code review and atomic deploy for nothing | +| **organization** | authored in the app, shared across one tenant | `agent_definitions` / `skill_definitions`, `visibility = 'organization'` | +| **personal** | authored in the app, private to one user | `agent_definitions` / `skill_definitions`, `visibility = 'personal'` | + +Before this work, the last two lived in `user_preferences.extra` — a jsonb blob with +no owner, no tenancy, no size bound, no server-side validation and no query surface, +returned in full by `GET /api/v1/me` on every page load. + +### Phase 4B — Definition Contract + +A definition is a Markdown document whose leading fenced block is YAML frontmatter +and whose remainder is the body. The parser contract is stated in +`internal/definition/definition.go`: + +- **The document layer** — byte-order mark, line endings, leading blank lines, fence + recognition, body extraction (`frontmatter.go`). +- **The YAML subset** — block maps and sequences, scalars, quoting, comments + (`yaml.go`). +- **Definition-level normalization and validation** — id, name, description, status, + version, pages, icons, reasoning, permissions, starters, knowledge, subagents. + +Those cover every column migration 000005 projects out of a definition: +`definition_id`, `status`, `version`, `name`, `description`, `pages`. + +Field-by-field detail for both kinds is in §9. + +### Phase 4C — Definition Database + +Migration **000005** creates `agent_definitions` and `skill_definitions` — two +tables, deliberately, not one. The reasoning is recorded in the migration: agents +have `draft | published | archived` plus a monotonic integer version; skills have +`active | inactive` and **no version at all**, because the frontend has no notion of +a skill version. One table would need a union CHECK permitting "version 5, status +inactive", and a version column forever equal to 1 for half the rows. + +The migration also records what it deliberately does not create: +`definition_versions` (nothing retains prior Markdown and no rollback feature exists +to serve), `definition_permissions` (the `permissions:` block stays inside the +Markdown, parsed and unenforced), `agent_skills` / `agent_subagents` (the `skills:` +list names ids in a namespace that includes shipped definitions, which have no rows +here, so a join table would need foreign keys to rows that do not exist), +`agent_knowledge`, and `conversations`. + +Schema detail is in §5. + +### Phase 4D — Parser Compatibility + +Two parsers read the same definition: the JavaScript in the frontend's +`src/lib/skills` and `src/lib/agents`, and the Go in `internal/definition`. If they +disagree, one of two silent failures occurs: a definition the editor accepts and the +API rejects looks valid while it is being written and fails when it is saved; or a +definition the API accepts and the editor rejects is stored and then cannot be +rendered by the product that owns it. + +The Go side is therefore a **port**, not an independent implementation, and +compatibility is enforced by replay rather than by description. Detail is in §10. + +### Phase 4E — Agent & Skill CRUD + +Ten HTTP routes — list, create, get, update, delete for each of agents and skills — +plus `internal/service/definitions.go` and `internal/repo/definitions.go`. Detail is +in §11. + +### Phase 4F — Runtime Boundary + +`internal/runtime` introduces the runtime representations (`runtime.Agent`, +`runtime.Skill`), the execution value types (`ExecutionInput`, `ExecutionResult`, +`RuntimeError`), a `Loader` that reads a stored definition and re-parses it, status +eligibility rules, dependency resolution with personal-over-organization shadowing, +the `AgentExecutor` / `SkillExecutor` interfaces, an `Engine` that ties them +together, and `UnavailableExecutor`. + +**The current runtime establishes the execution boundary.** `UnavailableExecutor` +returns `ErrExecutorUnavailable` from both `ExecuteAgent` and `ExecuteSkill`, and +performs no AI execution of any kind. Detail is in §12. + +--- + +## 3. Current Architecture + +### Request path + +``` +React (krow-demo — separate repository) + | +base44Client.js the one frontend file allowed to change + | +HTTP (JSON, /api/v1, cookie-bearing) + | +===================== krow-backend ===================== + | +Go HTTP server net/http.Server + ServeMux + | +requestLogger method, path, status, duration + | +cors installed only when an allowlist is configured + | +recoverer a panic becomes a logged 500, not a dropped connection + | +authenticate cookie -> session row -> user row -> Identity + OrgID + | +jsonErrors rewrites the mux's plain-text 404/405 into the envelope + | +HTTP handlers role gate (403), body decode, path values + | +Service layer query parsing, validation, contract semantics + | +Repository layer all SQL; org + ownership predicates; bind parameters only + | +pgx / pgxpool + | +PostgreSQL +``` + +### Definition and runtime path + +``` +Agent/Skill Markdown authored in the app, submitted as a JSON string + | +Parser internal/definition — frontmatter + YAML subset + | +Validator ValidateAgent / ValidateSkill + | +Definition Projection id, status, version, name, description, pages + | +PostgreSQL agent_definitions / skill_definitions + markdown stored verbatim; columns derived from it + | +Definition CRUD /api/v1/agent-definitions, /api/v1/skill-definitions + | +Runtime Loader internal/runtime — load by uuid or definition_id, + re-parse, re-validate + | +Dependency Resolution agent.Skills -> LoadSkill each, tenant + ownership + scoped, personal shadows organization, deduplicated + | +Executor Boundary AgentExecutor / SkillExecutor interfaces + (only UnavailableExecutor exists) +``` + +### Layer responsibilities + +| Layer | Package | Owns | Deliberately does not own | +| --- | --- | --- | --- | +| Entrypoint | `cmd/api` | config load, pool, logger, signal handling, graceful shutdown, session sweeper goroutine | routing, business rules | +| Config | `internal/config` | environment loading, typed defaults, validation | secrets (nothing is baked in) | +| Database | `internal/db` | pool lifecycle, startup ping, `Check` for `/health` | any DDL | +| HTTP | `internal/httpserver` | routing, middleware chain, role gate, response envelopes, cookies, rate limiting | SQL | +| Auth primitives | `internal/auth` | argon2id, token generation/hashing, session lifecycle, credential verification | HTTP, cookies, logging (nothing in the package logs) | +| Identity context | `internal/authctx` | the authenticated `Identity` on the request context | any setter that takes a user id from a client | +| Org context | `internal/orgctx` | the organization a request runs as | deciding which organization that is | +| Service | `internal/service` | query-string parsing, body validation, contract semantics | statement construction | +| Domain | `internal/domain` | resource descriptors (generated), the authorization policy table (hand-written), error constructors, record types | I/O | +| Repository | `internal/repo` | every SQL statement, organization scope, ownership predicates, derived columns | deciding *whether* a role may act | +| Definitions | `internal/definition` | frontmatter, the YAML subset, agent/skill normalization and validation | storage, HTTP, and the `ui:`/`owliver:` blocks | +| Runtime | `internal/runtime` | loading, eligibility, dependency resolution, the executor interfaces | executing anything | +| Seeder | `internal/seeder` | fixture loading, deterministic ids, upsert, shift generation and pruning | schema changes | +| Test harness | `internal/testutil` | a disposable migrated + seeded database per test process | touching the development database | + +**Where authorization happens, and why it is split.** The role check runs in the +handler *before any query*, so it always answers 403 and can never reveal whether a +row exists. Organization scope and ownership are SQL predicates in the `WHERE` +clause, so a row outside them is simply absent and answers 404. A caller cannot +distinguish "exists and is not yours" from "does not exist". Because the predicate +is in SQL, `count(*)` runs over the same clause — so `meta.total` for a talent +caller is their own count, not the organization's. + +--- + +## 4. Technology Stack + +### Current implementation — verified from this repository + +| Concern | What is used | Evidence | +| --- | --- | --- | +| Language | Go, module directive `go 1.27`; local toolchain `go1.27.0 darwin/arm64` | `go-api/go.mod`, `go version` | +| PostgreSQL driver | `github.com/jackc/pgx/v5 v5.10.0` (with `pgxpool`) | `go-api/go.mod` | +| Password hashing | `golang.org/x/crypto v0.42.0` (`argon2`) | `go-api/go.mod`, `internal/auth/password.go` | +| Terminal input | `golang.org/x/term v0.35.0` (hidden password prompt) | `go-api/go.mod`, `cmd/setpassword` | +| Indirect deps | `pgpassfile v1.0.0`, `pgservicefile`, `puddle/v2 v2.2.2`, `x/sync v0.17.0`, `x/sys v0.37.0`, `x/text v0.29.0` | `go-api/go.mod` | +| HTTP server | Go standard library only — `net/http.Server` and `http.ServeMux` with method+pattern routes (`"GET /api/v1/job-postings"`, `"{id}"` path values). No third-party router or framework. | `internal/httpserver/server.go`, `api.go` | +| Logging | `log/slog`, JSON handler to stdout, level from `LOG_LEVEL` | `cmd/api/main.go` | +| Migrations | `golang-migrate` CLI, wrapped by the `Makefile`; `-seq` numbering | `Makefile`, `README.md` (records version 4.19.1) | +| PostgreSQL | **18.6 (Homebrew)** on the local development machine | read-only `SHOW server_version` | +| Applied migration version | **5**, not dirty, in the local development database | read-only `SELECT version, dirty FROM schema_migrations` | +| Extensions installed | `citext`, `plpgsql`. **`pgvector` is not installed.** | read-only `SELECT extname FROM pg_extension` | +| Testing | Go's own `testing` package. No assertion library, no mocking framework, no test containers. Database-backed tests build a disposable database per test process. | `internal/testutil/db.go` | +| Parser | Hand-written, no YAML dependency — a port of the frontend's `yaml.js`. Conformance is asserted against a captured JS oracle fixture. | `internal/definition/yaml.go`, `conformance_test.go` | +| Runtime | Go, in-process, no external calls. Executor is an interface with one stub implementation. | `internal/runtime` | +| Code generation | `scripts/gen_resources.py` (Python 3, shells out to `psql`) | `Makefile` target `gen-resources` | +| Oracle capture | `scripts/oracle.mjs` + `scripts/cases.mjs` (Node, runs the frontend module graph through Vite) | `scripts/` | + +**Frontend transport relationship.** The frontend repository is not part of this +checkout. This backend serves the contract in `docs/api-contract.md`; the frontend +consumes it through `base44Client.js`. No frontend source is vendored, copied or +modified here. + +### Future / planned direction — not implemented + +Nothing in this list is present in the repository. See §18 for detail and §17 for +what the repository does and does not say about each. + +- Owliver (the planned Python service), LangGraph, or any orchestration layer +- Any LLM provider client +- RAG, embeddings, `pgvector` +- FastMCP or any tool-execution layer +- Redis, or any shared cache/rate-limit store +- Docker images or compose files +- Object storage +- OpenTelemetry or any tracing/metrics exporter + +--- + +## 5. PostgreSQL & Database Architecture + +### Database and schema + +- Local development database name and credentials come from environment variables; + `.env` is gitignored and `.env.example` contains only placeholders and safe local + defaults. **No password, key or token appears in any tracked file.** +- The application owns exactly one schema, defaulting to `public`, and + `config.validate()` refuses to point it at a PostgreSQL system schema. +- `migrations/` holds five up/down pairs. The local development database reports + applied version **5**, not dirty. + +### Migrations + +| File | Introduces | +| --- | --- | +| `000001_initial_schema` | 17 tables, 16 enum types, the `citext` extension, and the index and constraint set | +| `000002_application_interview_id` | `job_applications.interview_id`, `courses.training_outline`, `worker_profiles.score_breakdown` | +| `000003_drop_screened_consistent_check` | drops `job_applications_screened_consistent`; keeps the unused `screened_at` column | +| `000004_auth_sessions` | global unique index on `users.email`; the `sessions` table | +| `000005_agent_skill_definitions` | `agent_definitions`, `skill_definitions` | + +Every migration has a matching down file, and a test asserts that (`TestEveryMigrationHasADownFile`). Two further tests assert that 000004 and 000005 are reversible, and one asserts that 000005 adds exactly two tables and no others. + +**Migration rules recorded in `README.md`:** files are the source of truth; an +applied migration is immutable (write the next one instead); staging and production +run the same files as a discrete deploy step *before* the new binary rolls out, +which means every migration must be backwards-compatible with the currently-running +version — expand, migrate, contract, never in one migration. + +### Tables present in the local `public` schema + +Twenty application tables plus `schema_migrations` (21 base tables, verified +read-only): + +``` +organizations users user_preferences sessions +job_postings job_applications ai_interviews staff +worker_profiles assignments shift_records evidence +user_activity courses learning_paths role_categories +certifications badges agent_definitions skill_definitions +``` + +### Important tables in plain terms + +| Table | What it holds | Notable | +| --- | --- | --- | +| `organizations` | the tenant | every tenant-scoped table references it | +| `users` | people who can sign in | `role` (`admin`/`employer`/`talent`) is the authorization field; `account_type` is a display attribute; `password_hash` is nullable and NULL until set; `email` is `citext` and globally unique since 000004 | +| `sessions` | server-side sessions | stores only SHA-256 of the token; two expiries; `ON DELETE CASCADE` from `users` | +| `user_preferences` | per-user settings | three real columns plus an `extra` jsonb blob | +| `job_postings` | the shop window | `status` enum `draft/active/paused/closed`; `created_by` is server-owned; GIN index on `skill_requirements` | +| `job_applications` | applications | `status` enum carries seven values; `email` is `citext`; `interview_id` is a deliberate soft reference with no FK | +| `ai_interviews` | screening interview results | references an application; ownership is by reference, not by column | +| `worker_profiles` | a worker's own profile | `user_id` is server-owned for talent callers; GIN index on `completed_courses` | +| `staff` | the employment record of the workforce | operator-facing: endorsements, review dates, reviewer names | +| `assignments` | who is on which position | a GiST index over the assignment period | +| `shift_records` | attendance | read-only over the API; the seeder owns these rows | +| `evidence` | proof of work | submitted by a worker, verified by the organization | +| `user_activity` | the audit log | append-only by schema; four server-derived identity columns | +| `courses`, `learning_paths` | the training library | organization-nullable: `NULL org_id` is the shared platform library | +| `badges` | badge definitions | the table exists; **no endpoint reads it** (see §17) | +| `agent_definitions`, `skill_definitions` | authored definitions | see below | + +### Conventions + +- `uuid` primary keys (`gen_random_uuid()`), plus a nullable unique `legacy_id text`. +- `timestamptz` throughout; `created_date` / `updated_date` keep their frontend names. +- Native enums for closed vocabularies (16 types in 000001), `text` + `CHECK` where + the set is still moving. The definition tables use `text` + `CHECK` for both + `visibility` and `status`. +- Email columns are `citext`. +- 53 CHECK constraints were introduced in 000001; one was dropped in 000003. + +### Ownership and visibility model on the definition tables + +``` +agent_definitions / skill_definitions + + org_id NOT NULL, even for a personal definition + (a user belongs to exactly one organization, so the org + predicate applies to every read whether ownership does or not) + + visibility 'personal' | 'organization' CHECK + owner_user_id set if and only if visibility='personal' CHECK + ((visibility = 'personal') = (owner_user_id IS NOT NULL)) + ON DELETE CASCADE — a personal definition dies with its owner + + created_by always the author, for attribution + ON DELETE SET NULL — a shared definition survives its author +``` + +**Uniqueness is per tier, by two partial unique indexes** — not one global unique +constraint, because shadowing by id is the point: + +``` +..._personal_key UNIQUE (owner_user_id, definition_id) WHERE visibility='personal' +..._org_key UNIQUE (org_id, definition_id) WHERE visibility='organization' +``` + +Other constraints on both tables: `definition_id ~ '^[a-z0-9][a-z0-9-]*$'`; +`length(markdown) BETWEEN 1 AND 65536`; agent `status IN ('draft','published','archived')` +and `version >= 1`; skill `status IN ('active','inactive')` and no version column. + +Indexes: `(org_id, visibility)` for tier listing (which also covers the `org_id` FK), +`(owner_user_id)` for a user's own definitions and the cascade check, and a partial +index for the live rows — `(org_id, visibility) WHERE status='published'` for agents, +and the equivalent active-only index for skills. There is deliberately **no** index +on `created_by`, because it is attribution only. + +### Verified seed counts + +Counted directly from `seed/fixtures/seed.json` in this repository: + +| Entity | Records in fixture | +| --- | --- | +| Course | 40 | +| JobApplication | 24 | +| UserActivity | 15 | +| RoleCategory | 9 | +| WorkerProfile | 9 | +| JobPosting | 8 | +| Certification | 8 | +| AIInterview | 4 | +| Badge | 4 | +| Staff | 3 | +| Evidence | 3 | +| LearningPath | 2 | +| User | 1 | +| Assignment | 0 (empty in the source by design) | + +`ShiftRecord` is not in the fixture — it is generated. `docs/api-contract.md` §13.7 +records the generated figure as 115 records (2 absent, 1 no-show, 5 late, 107 +present) with the qualifier that the set is anchored to the day the seeder runs. + +`docs/api-contract.md` §13.7 also records that "6 job postings" in an earlier brief +was the *active* count; the fixture carries 8 (6 active, 1 paused, 1 closed). + +--- + +## 6. Seed & Data Strategy + +### Source + +`seed/fixtures/seed.json` is **generated**, not written: the frontend's +`src/api/seed.js` is executed through Vite and what it exports is serialised. The +reason is stated in `internal/seeder/seeder.go`: ids, dates, numbers and enum values +then arrive exactly as the demo has them, with no transcription step and nothing to +drift. + +### Shift records — generated, not snapshotted + +`internal/seeder/shifts.go` is a Go port of the frontend's `attendanceSeed.js`. +Shift records are the one collection whose dates are anchored to *now* rather than to +a fixed calendar: the frontend's `dataResolver.inPeriod` windows every collection on +`created_date`, so a frozen snapshot would read as permanently empty a fortnight +later, and the attendance and overtime features would have nothing to show. + +Nothing in the generator is random. The distribution is deterministic given the date +the seeder runs, over a 56-day window, across a three-person roster designed so the +demo has a control, an anomaly and a trend: + +| Person | Pattern | +| --- | --- | +| Marco Rivera | the control — reliable, weekend event overtime | +| Marcus Williams | attendance degrading over the last fortnight (the anomaly) | +| Chef Antoine Dubois | present throughout, overtime climbing week on week (the trend) | + +### UUID strategy + +`seeder.DeterministicUUID` derives a UUID v5 from the source id over a fixed +namespace (`krow-seed-v1...`), implemented with SHA-1 and the RFC 4122 version and +variant bits. Changing the namespace re-keys the entire dataset, so it is a constant +rather than configuration. Foreign key values in the fixture are run through the same +derivation, so a reference resolves to the row it named. + +### Idempotency + +Explicit upsert inside **one transaction**. Every record's primary key is derived +from its source id, so re-running targets exactly the same rows and +`ON CONFLICT (id) DO UPDATE` restores each one to its seeded values. Consequences, +stated in the package documentation: + +- Records created through the API **survive** a re-seed — nothing is deleted. +- A column the fixture does not carry is left as it is. +- Entities are inserted in a fixed order chosen so every foreign key is satisfied by + the time it is referenced. + +### The one exception: pruning + +Shift records are also **pruned** (`pruneShiftRecords`). Upsert alone cannot converge +a rolling window: yesterday's generated set and today's overlap but are not the same +set, so without a prune, re-seeding on a later day would leave stale rows behind. The +prune is scoped to the seeded organization, and the number pruned is reported in the +seeder's result rather than being silent — a delete during a seed should not be +something you have to read the source to discover. + +Four tests cover exactly this behaviour: `TestReseedOnALaterDayLeavesNoStaleShifts`, +`TestReseedIsConvergentAcrossAWeek`, `TestReseedSameInstantPrunesNothing`, and +`TestPruneIsScopedToTheSeededOrganization`. + +### Limitations + +- The demo user's `password_hash` is **NULL** after seeding. Nothing in the fixture, + the migrations or this repository contains, generates or defaults a password. A + password enters the system only through `cmd/setpassword`, typed by a person or + piped on stdin. +- `Assignment` is empty in the source and stays empty. +- Attendance and overtime are among the richest features in the product and will be + empty in any real deployment until a rostering source exists — an import, an + integration, or a scheduling UI, none of which exist. This is recorded as U1 in the + contract. + +--- + +## 7. API Architecture + +### Conventions + +| Aspect | Behaviour | +| --- | --- | +| Base path | `/api/v1` | +| Resource naming | kebab-case plural; mass nouns singular (`/staff`, `/evidence`, `/user-activity`) | +| Identifiers | `uuid` in the path; the frontend's original string ids live in `legacy_id` | +| Content type | `application/json; charset=utf-8` both ways | +| Field naming | snake_case, identical to the frontend's names. The single exception is `/me/preferences`, which is camelCase | +| Dates | ISO 8601 with offset, rendered by the database as `YYYY-MM-DDTHH:MM:SS.mmmZ` | +| Partial updates | `PATCH`, shallow merge. There is no `PUT` | +| Reserved query params | `sort`, `limit`, `offset`. Every other parameter is a field filter | +| Request body cap | 4 MiB (`maxBodyBytes`) | +| Cache headers | every JSON response sets `Cache-Control: no-store` | + +### Response envelopes + +Success — a record: + +```json +{ "data": { "id": "…", "title": "…", "created_date": "…" } } +``` + +Success — a collection: + +```json +{ + "data": [ … ], + "meta": { "total": 24, "limit": 100, "offset": 0, "returned": 24, "truncated": false } +} +``` + +`meta.truncated` is `total > offset + returned`. It exists so that the silent +truncation recorded in contract §12.2 is fixable without another contract change. + +Failure: + +```json +{ "error": { "code": "validation_failed", "message": "…", "details": { "field": "…" } } } +``` + +`details` is always present, as an object, even when empty. + +### Filtering, sorting, pagination + +- **Filtering** — a single value is equality (`?status=hired`); a repeated parameter + is membership (`?status=hired&status=interview`). A filter name is resolved to a + real column before any SQL is assembled; an unknown name answers 400. Array and + JSON columns are not filterable and say so. +- **Sorting** — `?sort=field` ascending, `?sort=-field` descending, `?sort=` (empty) + means unordered. Every ordered query renders `ORDER BY NULLS LAST, + .id` — nulls last in **both** directions, and the id tiebreaker always. +- **Pagination** — `limit` and `offset`, both non-negative integers. `limit` is + clamped to `MaxLimit = 1000`. Each resource's default limit is the literal argument + at its frontend call site. + +### Error codes and HTTP status + +| Code | HTTP | When | +| --- | --- | --- | +| `invalid_query` | 400 | a query parameter is malformed or names an unknown/unfilterable/unsortable field | +| `unauthorized` | 401 | no valid session | +| `forbidden` | 403 | the caller's role does not permit the operation | +| `not_found` | 404 | no such row within the caller's organization and ownership scope | +| `method_not_allowed` | 405 | the path exists but not under this method | +| `conflict` | 409 | a uniqueness constraint was violated | +| `validation_failed` | 422 | a body field is unknown, blank, null on a NOT NULL column, an invalid enum value, or a required field is absent | +| `rate_limited` | 429 | too many failed sign-in attempts; accompanied by `Retry-After` | +| `internal` | 500 | anything else; detail goes to the log, never to the client | + +A resource with no item route at all — `assignments`, which is only listed and +created — answers **404** rather than 405, because 405 requires the path pattern to +exist under some other method. + +### Endpoint groups + +**Routes registered by the server: 51.** +`routeResources` 34 + `routeMe` 4 + `routeAuth` 2 + `routeDefinitions` 10 + +`GET /health` 1. + +`docs/api-contract.md` §2 enumerates 38 endpoints; those are exactly the 34 resource +routes plus the 4 `/me` routes. The 2 auth routes are specified in §9 and the 10 +definition routes are not in the contract document at all (see §17). + +#### Health + +| Method | Path | Notes | +| --- | --- | --- | +| `GET` | `/health` | Public. Body is one field: `{"status": …}`. `"ok"` with 200; `"degraded"` with 200 when the database is up but unmigrated or left dirty; `"unavailable"` with 503 when it is unreachable, so a load balancer can act on the status code alone. Nothing about the server, database or schema appears in the body. | + +#### Authentication + +| Method | Path | Notes | +| --- | --- | --- | +| `POST` | `/api/v1/auth/login` | Public. Body `{"email", "password", "remember_me"}`. 200 returns the user in the same shape as `GET /me` and sets the session cookie. 422 when a field is absent. 429 when rate limited. Every credential failure is the same 401. | +| `POST` | `/api/v1/auth/logout` | Public and idempotent. Revokes the session behind the cookie if there is one, expires the cookie, and answers 200 with `{"data":{"status":"signed_out"}}` — including when the cookie is absent, stale, or was never valid. | + +#### Current user + +| Method | Path | Notes | +| --- | --- | --- | +| `GET` | `/api/v1/me` | The user behind the session cookie, with `preferences` embedded | +| `PATCH` | `/api/v1/me` | Shallow merge. Only `full_name` and `account_type` are writable | +| `GET` | `/api/v1/me/preferences` | camelCase keys | +| `PATCH` | `/api/v1/me/preferences` | Shallow merge, returns the merged object | + +#### Core application entities + +Registered only where the resource declares the operation, so an unsupported one is +answered by the mux rather than by a handler that has to know to refuse. + +| Resource | Registered operations | +| --- | --- | +| `job-postings` | list, get, create, update | +| `job-applications` | list, create, update, delete | +| `ai-interviews` | list, create | +| `staff` | list, create, update | +| `worker-profiles` | list, create, update | +| `courses` | list, get, create, update | +| `learning-paths` | list | +| `role-categories` | list, create | +| `certifications` | list, create, delete | +| `user-activity` | list, create | +| `evidence` | list, create, update | +| `assignments` | list, create | +| `shift-records` | list | +| `badges` | none — `Ops: 0` | + +#### Agent Definitions + +| Method | Path | +| --- | --- | +| `GET` | `/api/v1/agent-definitions` | +| `POST` | `/api/v1/agent-definitions` | +| `GET` | `/api/v1/agent-definitions/{id}` | +| `PATCH` | `/api/v1/agent-definitions/{id}` | +| `DELETE` | `/api/v1/agent-definitions/{id}` | + +#### Skill Definitions + +| Method | Path | +| --- | --- | +| `GET` | `/api/v1/skill-definitions` | +| `POST` | `/api/v1/skill-definitions` | +| `GET` | `/api/v1/skill-definitions/{id}` | +| `PATCH` | `/api/v1/skill-definitions/{id}` | +| `DELETE` | `/api/v1/skill-definitions/{id}` | + +Accepted query parameters on both collections: `visibility`, `status`, +`definition_id`, `sort`, `limit`, `offset`. Any other parameter answers 400. Default +limit 100; default sort `-created_date`. Sortable fields: `created_date`, +`updated_date`, `name`, `definition_id`, `status`, `version`. + +#### Runtime + +**Runtime execution is currently an internal backend boundary; no public runtime +execution endpoint is implemented.** No route in `internal/httpserver` references the +`runtime` package. + +--- + +## 8. Authentication, RBAC & Multi-Tenancy + +### Users + +`users` carries `email` (`citext`, globally unique since migration 000004), +`password_hash` (nullable, and NULL until a password is set), `role`, `account_type`, +`status`, `org_id` and `last_login_at`. `User.IsActive()` is `status == "active"`; +`User.CanAuthenticate()` additionally requires a non-empty password hash. + +### Password handling + +- **argon2id** via `golang.org/x/crypto/argon2`, with the OWASP-recommended + parameters: 64 MiB memory, 3 iterations, 4 lanes, 16-byte salt, 32-byte key. +- Hashes are stored as a complete self-describing PHC record, so verification reads + the parameters out of the stored string rather than assuming today's defaults — a + future cost increase does not invalidate existing hashes. `NeedsRehash` exists and + is tested. +- Minimum length **12 bytes** (a byte floor, because that is what the KDF consumes), + maximum **1024 bytes** (so an unbounded body cannot be hashed at 64 MiB per + attempt). +- Nothing in `internal/auth` logs, and neither a password, a token nor a hash is ever + returned in an error or formatted into a string. +- Passwords enter the system only through `cmd/setpassword`. There is deliberately no + `-password` flag — a password in argv is visible through `ps` and lands in shell + history — so the routes are an interactive hidden prompt (confirmed twice) or + stdin. + +### Sessions + +- A session is a **row**, not a token payload. The token is 32 random bytes from + `crypto/rand`, encoded as 43 characters of unpadded base64url. The database stores + only its lowercase hex **SHA-256**, pinned by a CHECK constraint — a raw token + fails that pattern, so the catastrophic-and-silent mistake of storing the secret + cannot pass. +- Plain SHA-256 rather than argon2 is deliberate and the reasoning is recorded: a + session token is 256 uniformly random bits, so there is no dictionary to run + against it and no work factor worth paying on every request. +- **Two expiries.** `expires_at` slides forward as the session is used; + `absolute_expires_at` is fixed at creation and never moves. Without the second, the + first could be slid indefinitely. + +| Session kind | Idle lifetime | Absolute cap | +| --- | --- | --- | +| normal | 12 hours | 24 hours | +| `remember_me` | 30 days | 90 days | + +- Sliding only happens once a session is past a threshold of its idle window, so a + page firing ten requests does not fire ten `UPDATE`s. The idle window is recovered + from the row rather than from the policy, so a session keeps the lifetime it was + issued under. +- A background sweeper deletes expired rows every **15 minutes**, running once + immediately at startup. Sweeping is housekeeping, not correctness: `Authenticate` + already refuses an expired session and deletes the row as it finds it. +- `ON DELETE CASCADE` from `users`, so a deleted user cannot leave a live session + behind. + +### Cookie + +`krow_session`, `Path=/`, `HttpOnly`, `SameSite=Lax`, `Max-Age` matching the +session's lifetime, and `Secure` whenever `APP_ENV != development`. The `__Host-` +prefix was considered and declined because it *requires* `Secure`, which cannot be +set over plain HTTP on localhost; the hardening is done by attributes instead, where +it can be conditional. `SameSite=Lax` rather than `Strict` (which would drop the +cookie on any cross-site navigation) or `None` (which would require `Secure` and send +the cookie on cross-site POSTs). + +**The token is never in a response body.** It goes out in a `Set-Cookie` header and +nowhere else. + +### Login flow + +1. Parse and validate the *shape* of the request. A missing field is a malformed + request (422), not a failed login. +2. Check both rate limiters, **before** any expensive work — an attacker must not be + able to make the server hash on their behalf. +3. Verify the credentials. `auth.Credentials.Verify` takes the same measurable time + whether the email exists or not: an unknown email, a user with no password set, + and a wrong password all pay a full argon2id derivation against a lazily-built + decoy hash. Account status is checked **after** the password, so a suspended + account is indistinguishable from a wrong password in both answer and timing. +4. Issue the session and set the cookie. A correct password clears the email's + failure counter; the address counter is left alone. +5. `last_login_at` is stamped best-effort, after the session exists — a failure there + is a lost diagnostic, not a reason to refuse a sign-in that already succeeded. + +Every credential failure produces one identical 401. The reason (`no_such_user`, +`no_password`, `bad_password`, `not_active`) goes to the log at `warn`, with the +email — which was already in the request — and never the password. + +**Rate limiting** is a fixed-window counter of *failed* attempts held in this +process's memory: 5 per email and 20 per client address per 15-minute window. Two +limiters rather than one, because the budgets are deliberately different sizes — an +email is one account, while an address may be a whole office behind NAT. Its +limitations are documented in the source itself and repeated in §17. + +### Authentication middleware + +``` +publicPaths = { /health, /api/v1/auth/login, /api/v1/auth/logout } +``` + +An **allowlist**, not a list of protected prefixes, so the failure mode of forgetting +to update it is a route that refuses everyone rather than one that serves everyone. +It sits below the router, so even the mux's own 404 is behind it. + +For every other request: + +``` +cookie -> Manager.Authenticate(token) + hash the token, look up the row, refuse if expired (and delete it), + slide the expiry if due + -> users.FindByID(session.UserID) + re-read on EVERY request, not cached in the session row, so suspending + an account takes effect on its next request + -> refuse and revoke if the user is not active + -> authctx.Identity{UserID, OrgID, Email, FullName, Role, AccountType, + Status, SessionID, ExpiresAt} on the context + -> orgctx.With(ctx, user.OrgID) +``` + +**Nothing in the request influences any of it.** The middleware reads no body, no +query string and no header other than `Cookie`. A `user_id` or `org_id` in a body or +query string is ignored. + +### Roles and the authority + +`users.role`, and only `users.role`. Three values, fixed by the `users_role_check` +constraint. An unrecognised role authorizes nothing. + +| Role | Who | +| --- | --- | +| `admin` | runs the platform for the organization | +| `employer` | runs the organization's hiring and workforce | +| `talent` | a worker, acting for themselves | + +`account_type` is **not** an authorization field. It is a display attribute the user +may change on themselves through `PATCH /me`, and nothing in the API reads it to make +a decision. + +### Access resolution + +``` + User (session -> users row) + | + +--> Organization org_id, from the user's row, never from the request + | | + | +--> org predicate on every read and write + | (courses / learning_paths also match org_id IS NULL — + | the shared platform library) + | + +--> Role (admin | employer | talent) + | + +--> handler gate: may this role perform this operation? + | no -> 403, before any query runs + | + +--> row predicate: which rows may this role see? + admin, employer -> the whole organization + talent -> an extra WHERE clause + -> outside it: 404 +``` + +### Permission matrix + +R = read (list/get), C = create, U = update, D = delete. `own` means restricted by +a SQL predicate. + +| Resource | Admin | Employer | Talent | +| --- | --- | --- | --- | +| `/me`, `/me/preferences` | R U | R U | R U | +| `job-postings` | R C U | R C U | R *(active only)* | +| `job-applications` | R C U D | R C U D | R C *(own)* | +| `ai-interviews` | R C | R C | R C *(own)* | +| `staff` | R C U | R C U | — | +| `worker-profiles` | R C U | R C U | R C U *(own)* | +| `assignments` | R C | R C | R *(own)* | +| `shift-records` | R | R | R *(own)* | +| `courses` | R C U | R | R | +| `learning-paths` | R | R | R | +| `role-categories` | R C | R C | R | +| `certifications` | R C D | R C | R | +| `user-activity` | R C | R C | R *(own)* C | +| `evidence` | R C U | R C U | R C *(own)* | +| `badges` | — | — | — | + +Two admin-only operations, each with a reason beyond seniority: `POST`/`PATCH +/courses`, because a course with a `NULL org_id` is the shared library and a write +there can reach beyond the writer's own tenant; and `DELETE /certifications/{id}`, +because deleting one changes what every existing posting that required it means. + +### Ownership predicates (talent callers) + +| Resource | Predicate | +| --- | --- | +| `worker-profiles` | `user_id = ` | +| `job-applications` | `email = ` | +| `assignments` | `worker_email = ` | +| `shift-records` | `worker_email = ` | +| `evidence` | `worker_email = ` | +| `user-activity` | `user_email = ` | +| `ai-interviews` | `application_id IN (SELECT id FROM job_applications WHERE org_id = … AND email = )` | +| `job-postings` | `status = 'active'` — visibility rather than ownership | + +The `ai-interviews` case also has a **write** guard: an insert whose ownership is +expressed by reference has the reference checked against the caller's own +applications, and a reference that is not theirs answers the same 404 a nonexistent +application would. + +### Server-owned identity columns + +Six columns are filled from the session and ignored if present in a request body, +because they are the columns every ownership rule rests on: + +| Column | Filled with | When | +| --- | --- | --- | +| `job_postings.created_by` | session user id | always | +| `user_activity.user_id` | session user id | always | +| `user_activity.user_email` | session email | always | +| `user_activity.user_name` | session full name | always | +| `user_activity.account_type` | session account type | always | +| `worker_profiles.user_id` | session user id | talent callers only | + +Two more are overridden for talent callers and left writable for operators: +`job_applications.email` and `evidence.worker_email`. The distinction is between *who +acted* and *who the row is about* — when an admin creates a candidate's profile or +files an application on their behalf, the subject is the candidate, not the operator. + +`PATCH /me` has its own allowlist: only `full_name` and `account_type` are writable. +`role` used to be in that map, which would have been a one-line privilege-escalation +path the instant sessions existed. Attempts to write a server-owned user field are +ignored and **logged**, so a client asking to change its own role is visible even +though the answer is no. + +### Cross-user and cross-tenant protection + +Both are predicates, not filters, so a row outside them is never fetched. Tests +covering this: `TestCrossOrganizationIsolation`, `TestTalentSeesOnlyTheirOwnRecords`, +`TestTenancyFollowsTheSession`, `TestIdentityCannotBeSuppliedByTheRequest`, +`TestForbiddenVersusNotFound`, `TestOrganizationScoping`, +`TestTalentCannotInterviewForAnotherApplication`. + +### Deny by default + +`internal/domain/policy.go` maps a URL path to a `*Policy`. A resource with **no +entry keeps a nil policy and permits nothing, to anyone** — a table added to the +schema tomorrow is unreachable until someone writes down who may reach it. +`TestEveryResourceHasAPolicy` makes the omission loud. `badges` has an empty policy +written out explicitly, so the resource is deliberately closed rather than merely +forgotten. + +### What is not implemented + +No permissions table, no policy engine, no per-record ACL, no role hierarchy, no +delegation, no refresh tokens, no multi-factor, no SSO, no OAuth, no password reset +flow, no self-service registration, and no email delivery of any kind. + +**Firebase Auth is an architectural direction, not the current implementation.** The +string "Firebase" does not appear anywhere in this repository — *Not verified from +current repository* as a recorded plan; it is recorded here because it was named as a +direction for this document. + +--- + +## 9. Agent & Skill System + +### The three representations + +| Representation | Where | Purpose | +| --- | --- | --- | +| **Source-controlled (shipped)** | `src/agents/**/*.md`, `src/skills/**/*.md` in the frontend repository | product source, versioned in Git, bundled at build time. **No rows in this database.** | +| **PostgreSQL authored** | `agent_definitions`, `skill_definitions` | authored in the app; the Markdown is the authoritative artefact, the columns are projections | +| **Runtime** | `runtime.Agent`, `runtime.Skill` | the parsed definition plus its database identity and ownership, prepared for execution | + +The three tiers share one id namespace, and shadowing across them is the point: a +personal definition may carry the same `definition_id` as an organization one, which +may carry the same id as a shipped one. + +### Agent — fields parsed by `internal/definition` + +`definition.Agent`: + +| Field | Type | Notes | +| --- | --- | --- | +| `ID` | string | from `id:`; falls back through name and then the origin path | +| `Name` | string | falls back to `Untitled agent`, which the validator then refuses | +| `Description` | string | | +| `Status` | string | `draft` / `published` / `archived` | +| `Version` | int | | +| `Pages` | []string | **canonical** surface ids — mapped through the frontend's `canonicalPage` | +| `Icon` | string | checked against a closed vocabulary (10 values in the captured oracle) | +| `Reasoning` | string | `fast` / `balanced` / `deep` | +| `Trigger` | string | | +| `WebSearch` | bool | | +| `Skills` | []string | ids of skills the agent depends on | +| `Subagents` | []string | | +| `Starters` | []Starter | `{Label, Prompt}` — conversation starters | +| `Knowledge` | []Knowledge | `{ID, Label, Kind, Body, URL}`; kinds `note` / `link` / `skill-reference` | +| `Permissions` | Permissions | `{Owner, Access, People[]}`; access `all` / `specific`; roles `manager` / `editor` / `viewer`. **Parsed and not enforced** | +| `Instructions` | string | the body's `## Instructions` section — prose belongs under a heading | +| `Errors` | []string | what this definition lost on the way in, carried on the record rather than thrown | +| `Body` | string | the Markdown after the frontmatter | + +`runtime.Agent` additionally carries `DatabaseID`, `Visibility`, `OwnerUserID`, +`ResolvedSkills` and `RawMarkdown`. + +### Skill — fields parsed by `internal/definition` + +`definition.Skill`: + +| Field | Type | Notes | +| --- | --- | --- | +| `ID` | string | | +| `Name` | string | falls back to `Untitled skill` | +| `Description` | string | | +| `Status` | string | `active` / `inactive` | +| `Pages` | []string | **as the author wrote them**, not canonicalised | +| `Kind` | string | | +| `Category` | string | | +| `Actions` | []string | | +| `Triggers` | []string | | +| `DeclaredTriggers` | bool | whether the author declared any | +| `Prompt` | *string | nullable | +| `SkillID` | *string | nullable | +| `Levels` | []Level | `{Level, Label, Summary}`, read from the body's own headings | +| `Body` | string | Markdown after the frontmatter, trimmed | +| `Deferred` | []string | frontmatter blocks this package does not check — see §10 | + +`runtime.Skill` additionally carries `DatabaseID`, `Visibility`, `OwnerUserID` and +`RawMarkdown`. + +### One asymmetry that must not be "fixed" + +`Agent.Pages` holds **canonical** surface ids; `Skill.Pages` holds the strings the +author wrote. That is not an oversight: the frontend's `normalizeAgent` maps every +page through `canonicalPage` and its `parseSkill` does not. Making the two agree in +Go would make each one disagree with its own editor. The comment in the source says +so explicitly. + +### Vocabularies (captured from the frontend) + +18 pages with aliases; agent statuses `draft`/`published`/`archived`; reasoning +`fast`/`balanced`/`deep`; 10 icons; knowledge kinds `note`/`link`/`skill-reference`; +access `all`/`specific`; permission roles `manager`/`editor`/`viewer`. +`TestVocabularyMatchesFrontend` asserts the Go tables against the captured ones. + +### Backend-only bounds + +Two rules the frontend editor does not have, both flagged `BackendOnly` so they are +distinguishable from shared rules: + +- `MaxMarkdownLength = 65536` **characters** (PostgreSQL `length()` counts + characters), matching the `markdown_size` CHECK. +- `MaxVersion = 2147483647`, the range of `agent_definitions.version` as a PostgreSQL + `integer`. + +Both turn a constraint violation into a message an author can act on. + +--- + +## 10. Parser & Definition Compatibility + +### Why the two parsers must agree + +``` +Frontend JS parser (src/lib/skills, src/lib/agents) + | + compatibility contract (internal/definition + testdata/oracle.json) + | +Backend Go parser (internal/definition) +``` + +A definition is authored in the browser and stored by the server, so both parsers see +it. Disagreement is silent in both directions: a definition the editor accepts and +the API rejects fails at save time after looking valid; a definition the API accepts +and the editor rejects is stored and then cannot be rendered. + +### The supported YAML subset + +`internal/definition/yaml.go` is a line-for-line port of the frontend's `yaml.js`. + +**Supported, and nothing else:** block maps and block sequences nested to any depth; +scalars (strings, integers, floats, booleans, null); quoted strings for values +containing `:` or `#`; `- key: value` (a mapping whose first key sits on the dash); +`#` comments and blank lines. + +**Not supported:** anchors, aliases, merge keys, multi-document files, flow mappings, +flow sequences, block scalars, tags. These are not silently half-read — an +unparseable line is an error carrying its 1-based line number *within the +frontmatter*, worded exactly as the frontend words it, because an author who sees one +message in the editor and another from the API is being told about two different +problems. + +**No YAML dependency, deliberately.** A general library would accept a much larger +language than the frontend does, and every construct it accepted and the frontend did +not would be a definition the backend stores and the editor cannot read. + +**Nothing here evaluates anything.** There is no reflection, no template, and no code +path from a definition to execution of any kind. A definition is configuration, and +the parser is the boundary that keeps it configuration. + +### Document layer + +`frontmatter.go` handles byte-order mark stripping, CRLF normalisation, leading blank +lines, fence recognition (including a trailing tab after the closing fence), and body +extraction. A second `---` in the document is body, not a new frontmatter block. + +### Validation and normalization + +- Everything is optional. A definition declaring only an id and a name normalizes to + a working agent with documented defaults. +- Nothing unknown survives — statuses, reasoning modes, pages, icons, knowledge kinds + and permission roles are checked against closed tables, and an unrecognised value + is a *named error* rather than a dropped key. +- What validates is kept. One bad entry costs its author that entry and a message, + never the rest of the file (`Agent.Errors`). +- An agent with **no skills** is deliberately not refused, because several product + pages have no assistant skills and answer from their own responder. +- The rejection messages reuse the frontend's own wording wherever the rule is + shared, including its quirks — `ValidateAgent` performs a literal + `strings.Contains(raw, "name:")` substring test because the JavaScript does. + +### Raw Markdown is never rewritten + +Normalization reads; it does not rewrite what is stored. The Markdown handed in is +the Markdown that goes to the database, byte for byte. +`TestParsingDoesNotMutateSource`, `TestNormalizationIsNotStorage` and +`TestMarkdownIsStoredVerbatim` assert it from three directions. + +### Deferred blocks + +A skill may carry a `ui:` block (declarative page sections) or an `owliver:` block +(assistant capabilities). Validating those means reproducing roughly 1,500 lines of +closed vocabulary describing what the **frontend** can render — placements, data +sources, section types, periods — none of which the backend stores, projects or acts +on. + +The package therefore does not check them. It records their presence on +`Skill.Deferred`, so the gap is a value a caller can see rather than an assumption. +The one consequence is stated exactly in the source: `skill-examples/board-invalid-context.md` +is rejected by the frontend on a rule about which placement may supply which data +source, and accepted here. It is the only definition in the corpus where the two +disagree, and the conformance suite asserts that it stays the only one. + +Visibility is deliberately absent from the parser: the frontend ignores a +`visibility:` key in a definition entirely. It is a storage tier chosen by the +request and checked by a database constraint. **A definition cannot name its own +tenancy.** + +### The conformance corpus + +`internal/definition/testdata/oracle.json` (9,633 lines) is **not hand-written**. It +is captured by `scripts/oracle.mjs`, which loads the real frontend module graph +through Vite — `import.meta.glob`, the `@/` alias and raw Markdown loading behave +exactly as they do in the app — and records what the JavaScript parser did with every +definition. Counted from the fixture in this repository: + +| Set | Entries | Breakdown | Accepted by JS | +| --- | --- | --- | --- | +| `corpus` (shipped definitions) | **37** | 28 skills, 9 agents | 36 accepted, 1 rejected | +| `cases` (adversarial) | **132** | 87 skills, 45 agents | 86 accepted, 46 rejected | + +The adversarial cases come from `scripts/cases.mjs`, which stores raw bytes as an +author could actually produce them — nothing is normalized on the way in, because the +point is what the two parsers do with the awkward form. + +The assertion is therefore not "Go agrees with a description of the frontend" but +"Go agrees with the frontend", replayed. A frontend change that alters parsing fails +these tests, which is the intent: the contract cannot drift silently in either +direction. + +### Mutation checks + +`TestMutationsWouldBeCaught` exists because tests that pass against a broken parser +are not tests. Each entry is a plausible mistake in the Go package, and each must be +caught by a real definition changing its meaning rather than by an assertion written +to notice it. The mutations covered include: dropping the BOM strip; dropping CR +normalisation (so `candidates\r` stops being a page); trimming the closing fence too +eagerly or not at all; treating a second `---` as frontmatter; treating a `#` inside +a word as a comment; failing to treat a spaced `#` as a comment; letting a colon +inside quotes split the value; taking the first rather than the last of a duplicate +key; accepting ragged indentation instead of reporting its line; and canonicalising a +skill's page name. + +### Parser behaviours worth knowing + +- `pages: candidates` (a bare string, not a sequence) is **accepted for an agent and + refused for a skill**, because `normalizeAgent` coerces and `parseSkill` requires a + real sequence. +- A definition with no `id:`, no `name:` and a valid `pages:` list gets the id + `custom` on the frontend and is accepted; the Go parser derives the same id from + the same default parameter (`AuthoredPath = "custom"`) so that it does not refuse a + definition the editor accepts. + +--- + +## 11. Agent & Skill CRUD + +### Create flow + +``` +POST /api/v1/agent-definitions { "markdown": "...", "visibility": "personal" } + | +authctx.MustFrom(ctx) identity from the session + | +body["markdown"] present? absent -> 422 "Paste or upload a Markdown definition." + | +ValidateAgent(markdown) frontend rules + backend bounds -> 422 with the message + | +visibility resolved defaults to "personal"; must be personal|organization + | +organization? role gate talent (or an unrecognised role) -> 403 + | +ParseAgent(markdown) -> id, status, version, name, description, pages + | +AgentInsertInput org_id = session org + created_by = session user + owner_user_id = session user (personal only) + markdown = the caller's bytes, unchanged + | +INSERT ... RETURNING partial unique index -> 409 on a duplicate id per tier + | +{ "data": { … the stored row … } } +``` + +Skills follow the same path through `ValidateSkill` / `ParseSkill`, minus `version`. + +### List, get, update, delete + +| Operation | Behaviour | +| --- | --- | +| `GET` collection | Scoped to the caller's organization **and** ownership: `visibility='organization' OR (visibility='personal' AND owner_user_id = )`. Optional `visibility` filter narrows to one tier. Returns the standard page envelope with `meta`. | +| `GET /{id}` | Same predicate. A non-UUID id, or a row outside the predicate, answers 404 — the caller cannot distinguish the two. | +| `PATCH /{id}` | Reads the row first (within scope). If the stored row is `organization`, a talent caller is refused 403. `visibility` cannot be changed after creation — an attempt answers 422 `immutable`. If `markdown` is supplied it is re-validated, re-parsed, and **every projection is recomputed from it**. Otherwise a bare `status` change is accepted against the closed status list. | +| `DELETE /{id}` | Idempotent, matching the contract's §12.7 semantics: a non-UUID id or an already-absent row answers 200 with `{"data":{"id":"…"}}`. An `organization` row still enforces the role gate before deleting. | + +### Scope, ownership and tenancy + +- **Personal scope** — `visibility='personal'`, `owner_user_id` set to the session + user by the server. A caller never supplies it. +- **Organization scope** — `visibility='organization'`, `owner_user_id` NULL, + writable by admin and employer only. +- **Tenant isolation** — `org_id = ` is on every read, update and + delete, including for personal definitions, whose `org_id` is `NOT NULL` for + exactly this reason. +- **Duplicate handling** — the two partial unique indexes make an id unique per owner + or per tenant, never globally. A collision surfaces as `conflict` / 409 through the + repository's error translation. +- **Server-owned fields** — `id`, `org_id`, `owner_user_id`, `created_by`, + `created_date`, `updated_date`, and every projection (`definition_id`, `status`, + `version`, `name`, `description`, `pages`). The only client-supplied values are + `markdown`, `visibility` (at creation), and a bare `status` on `PATCH`. +- **Projection synchronization** — a `PATCH` that changes `markdown` recomputes every + projected column in the same statement, so a column can never describe a different + document than the one stored. +- **Verbatim Markdown** — the bytes the caller sent are the bytes stored. + +### Tests covering this surface + +`TestAgentCreate`, `TestSkillCreate`, `TestDefinitionsList`, `TestDefinitionsGet`, +`TestDefinitionsPatch`, `TestDefinitionsDelete`, `TestSecurityAndSQLInjection`, +`TestFullCRUDFlowAndProjections` (`internal/httpserver/definitions_api_test.go`, 848 +lines). + +--- + +## 12. Runtime Architecture + +### What the package contains + +| Type | Role | +| --- | --- | +| `Loader` | loads a stored definition by UUID or `definition_id`, re-parses and re-validates it, and returns a runtime representation | +| `runtime.Agent` | parsed agent + `DatabaseID`, `Visibility`, `OwnerUserID`, `ResolvedSkills`, `RawMarkdown` | +| `runtime.Skill` | parsed skill + the same identity and ownership fields | +| `ExecutionInput` | `{Identity, TargetID, Input, Parameters, Context}` | +| `ExecutionResult` | `{Success, Output, AgentID, AgentVersion, ResolvedSkills, Error}` | +| `RuntimeError` | structured `{Code, Message, Target, Cause}` with `Unwrap` | +| `AgentExecutor` / `SkillExecutor` | the execution boundary interfaces | +| `UnavailableExecutor` | the only implementation; refuses execution | +| `Engine` | ties loader + executors together; `RunAgent`, `RunSkill` | + +### Typed errors + +`ErrNotFound`, `ErrUnauthorized`, `ErrInvalidDefinition`, `ErrDraftAgent`, +`ErrArchivedAgent`, `ErrInactiveSkill`, `ErrNotExecutable`, `ErrDependencyMissing`, +`ErrDependencyInactive`, `ErrCircularDependency`, `ErrExecutorUnavailable`. + +### Loading + +`LoadAgent` / `LoadSkill` accept either a UUID (matched by regex) or a +`definition_id`, and dispatch to the repository accordingly. The stored `markdown` is +then re-validated and re-parsed — the runtime does not trust the projected columns +for anything but identity, visibility and ownership. A row whose `markdown` is absent +or empty answers `ErrInvalidDefinition`. + +Both lookups go through `DefinitionsRepo`, so **tenant and ownership scoping applies +to the runtime exactly as it does to the CRUD API**: `org_id = ` and +`visibility='organization' OR (visibility='personal' AND owner_user_id = )`. + +### Status eligibility + +``` +Agent Skill + published -> eligible active -> eligible + draft -> ErrDraftAgent inactive -> ErrInactiveSkill + archived -> ErrArchivedAgent other -> ErrNotExecutable + other -> ErrNotExecutable +``` + +`LoadExecutableAgent` applies the agent rule and then resolves dependencies; +`LoadExecutableSkill` applies the skill rule. + +### Dependency resolution + +``` +Agent + | +agent.Skills (ids from the `skills:` frontmatter) + | +for each id, in declared order: + | + +-- already resolved? -> skip (deterministic deduplication) + | + +-- LoadSkill(ident, id) tenant + ownership scoped + | | + | +-- by definition_id: ORDER BY + | CASE WHEN visibility='personal' THEN 1 ELSE 2 END + | LIMIT 1 personal shadows organization + | | + | +-- not found -> ErrDependencyMissing + | + +-- status != 'active' -> ErrDependencyInactive + | + v +agent.ResolvedSkills ([]*Skill, in declared order) +``` + +An agent with no `skills:` entries resolves to an empty list rather than an error. + +**Cross-tenant dependency protection** falls out of the repository predicate: a skill +belonging to another organization is simply not found, so the dependency resolves to +`ErrDependencyMissing` rather than to another tenant's definition. +`TestRuntime_DependencyResolution` covers the cross-org case explicitly, and +`TestRuntime_PersonalSkillShadowing` covers the shadowing precedence. + +**Cycle detection** is present in the code (`inProgress` map, `ErrCircularDependency`) +but is not reachable in the current design — see §17. + +### The executor boundary + +```go +type AgentExecutor interface { + ExecuteAgent(ctx, agent *Agent, input ExecutionInput) (*ExecutionResult, error) +} +type SkillExecutor interface { + ExecuteSkill(ctx, skill *Skill, input ExecutionInput) (*ExecutionResult, error) +} +``` + +`NewEngine` defaults both to `UnavailableExecutor` and accepts `WithAgentExecutor` / +`WithSkillExecutor` overrides. `UnavailableExecutor.ExecuteAgent` returns a result +carrying the agent id, version and resolved skill ids, with `Success: false` and +`Error: ErrExecutorUnavailable`, and returns that error. + +**Real LLM execution is not part of the current executor boundary.** There is no LLM +client, no prompt assembly, no tool dispatch and no external call anywhere in +`go-api/`. The boundary exists so that adding one is an implementation of an +interface rather than a change to the loading, scoping and eligibility rules. + +### Reachability + +Nine test functions in `internal/runtime/runtime_test.go` (890 lines) exercise the +package: loader, status eligibility, version semantics, dependency resolution, +executor boundary, personal skill shadowing, malformed Markdown, and skill execution. +Outside those tests, **nothing in the repository calls into the package** — see §17. + +--- + +## 13. Security Architecture + +### Controls that are present and verifiable + +| Control | How it is implemented | +| --- | --- | +| **SQL parameterization** | Every value reaches PostgreSQL as a bind parameter cast to its declared type. No identifier ever comes from user input: a filter or sort name is resolved to a `*domain.Column` before any SQL is assembled, and an unresolved name is rejected. `TestSecurityAndSQLInjection` covers the definitions surface. | +| **Deny-by-default authorization** | A resource with no policy permits nothing to anyone. `TestEveryResourceHasAPolicy`, `TestNilPolicyDeniesEverything`, `TestUnknownRoleIsDenied`. | +| **Tenant isolation** | `org_id = ` is a predicate on every read and write, taken from the user's row and never from the request. | +| **Ownership checks** | Predicates in the same `WHERE` clause, so `count(*)` matches what the caller may see and no unauthorized row is ever fetched. | +| **Server-owned fields** | Eight identity columns are filled from the session and override anything in the body; `PATCH /me` writes only two fields, and attempts on the rest are ignored **and logged**. | +| **Password handling** | argon2id at OWASP parameters, self-describing PHC records, a 12-byte floor and a 1024-byte ceiling, no password ever logged or echoed, and no `-password` CLI flag. | +| **Credential-failure uniformity** | One 401 for every failure mode, and a decoy argon2id derivation so an unknown email costs the same time as a known one. Account status is checked after the password. | +| **Sessions** | 256-bit random opaque tokens; only SHA-256 stored, pinned by a CHECK; sliding plus absolute expiry; per-request user re-read so suspension takes effect immediately; expired sessions deleted on contact and swept every 15 minutes. | +| **Cookies** | `HttpOnly`, `SameSite=Lax`, `Path=/`, `Secure` outside development, `Max-Age` matching the session. The token never appears in a response body. | +| **Rate limiting** | Failed sign-in attempts counted per email (5) and per client address (20) in a 15-minute window, checked **before** any hashing. | +| **CORS** | Exact-match allowlist, echoed one origin at a time, `*` rejected at config load, `Vary: Origin` always set, and the middleware not installed at all when the allowlist is empty. | +| **Input validation** | Unknown body fields answer 422 with the field named; enum values are checked against the column's declared set; `NOT NULL` columns refuse an explicit null; required fields are enforced on create; request bodies are capped at 4 MiB. | +| **Health endpoint exposure** | One status word and nothing else. Version, database name, schema, migration version, table count, error text, environment and uptime all moved to the server log. `TestHealthLeaksNoInfrastructure`, `TestHealthUnavailableSaysNothingAboutWhy`. | +| **Error exposure** | An unrecognised error is flattened to `{"code":"internal","message":"internal error"}`; the detail goes to the log. A 403 names neither the caller's role nor the roles that would have worked. | +| **Existence non-disclosure** | 403 for role, 404 for row. A caller cannot tell "exists and is not yours" from "does not exist". `TestForbiddenVersusNotFound`. | +| **Panic containment** | `recoverer` turns a panic into a logged 500 rather than a dropped connection. | +| **Config guards** | System schemas rejected; `sslmode=disable` rejected in production; CORS `*` rejected. | +| **Operational guards** | No `make migrate-drop`; `migrate-down` gated on `APP_ENV=development`; test databases are named `krow_backend_autotest_` and never touch the development database. | +| **Secret hygiene** | `.env` is gitignored; `.env.example` carries no real values; the connection string is redacted in logs (`DBConfig.Redacted`). | + +### Honest limitations + +These are the repository's own words where it states them, and direct observation +where it does not: + +- **Rate limiting is per-process and in-memory.** Two API instances behind a load + balancer each allow the full budget, so the effective limit is the limit times the + instance count, and a restart clears every counter. Multi-instance deployment needs + shared state. +- **The client address is `RemoteAddr`.** Behind a reverse proxy every request appears + to come from the proxy, so the per-address budget becomes global. Reading + `X-Forwarded-For` instead would be *worse* until a trusted-proxy list exists, + because a client can send that header itself and mint a fresh budget per request. +- **The limiter map is bounded by pruning, not by a hard cap**, so a flood from many + distinct addresses grows it until the next prune. +- **There is no CSRF token.** The defence is `SameSite=Lax`, which does not send the + cookie on cross-site POST/PATCH/DELETE. That is adequate for the current + same-origin and localhost-development posture and would need re-examination + alongside any change to the cookie's `SameSite` value. +- **CORS never sets `Access-Control-Allow-Credentials`.** See §17. +- **The `permissions:` block in a definition is parsed and not enforced.** No + `definition_permissions` table exists. +- **No rate limiting exists on any endpoint other than login.** +- **No audit trail of authorization refusals beyond the application log.** +- **No secret management, key rotation, or encryption at rest** is configured in this + repository; those are deployment concerns and `infrastructure/` is empty. + +Nothing here should be read as a claim that the service is production-hardened. It is +a claim about which controls exist and which do not. + +--- + +## 14. Testing & Verification + +### Toolchain checks — run against this repository + +| Check | Command | Result observed | +| --- | --- | --- | +| Formatting | `gofmt -l ./cmd ./internal` | no files listed | +| Static analysis | `go vet ./...` | no diagnostics | +| Build | `go build ./...` | builds | +| Tests | `go test ./... -count=1` | every package's tests ran without failure | + +### Test counts — counted from this repository + +| Measure | Count | +| --- | --- | +| Top-level `Test…` functions | **175** | +| Total test entries executed, including subtests | **920** | +| Failures | **0** | +| Skipped | **0** (PostgreSQL was reachable on the machine used) | + +Per package: + +| Package | Top-level test functions | Lines of test code (largest files) | +| --- | --- | --- | +| `internal/httpserver` | 78 | `api_test.go` 1,114; `auth_test.go` 926; `definitions_api_test.go` 848; `rbac_test.go` 730 | +| `internal/auth` | 26 | `session_test.go` 488; `schema_test.go` 376; `password_test.go` 254 | +| `internal/domain` | 22 | `definitions_schema_test.go` 748; `policy_test.go` 191 | +| `internal/seeder` | 18 | `seeder_test.go` 300; `shifts_convergence_test.go` 178 | +| `internal/definition` | 13 | `conformance_test.go` 928 | +| `internal/runtime` | 9 | `runtime_test.go` 890 | +| `internal/service` | 9 | `service_test.go` 217 | + +`cmd/api`, `cmd/seed`, `cmd/setpassword`, `internal/authctx`, `internal/config`, +`internal/db`, `internal/orgctx`, `internal/repo` and `internal/testutil` have no test +files of their own; `internal/repo` is exercised through the service and httpserver +suites. + +### Database-backed tests + +`internal/testutil` builds a disposable database per test **process**: dropped, +recreated, migrated and seeded, named `krow_backend_autotest_` so that +concurrently-running test packages cannot collide, and named distinctly enough that +it cannot be confused with a real database. The package documentation states that +nothing in it ever connects to, reads or drops the development database. Tests skip +rather than fail when PostgreSQL is unreachable. + +### What the important tests prove + +| Area | Representative tests | What they establish | +| --- | --- | --- | +| Contract semantics | `TestNullsSortLastInBothDirections`, `TestStableSortWithIDTiebreaker`, `TestEndpointSpecificDefaults`, `TestLimitAndTruncationMeta`, `TestOffsetPaging` | The two ordering guarantees hold, and each endpoint carries its own defaults. The README records that the ordering tests were verified to fail when the guarantee is removed. | +| Validation | `TestCreateRejectsUnknownFields`, `TestCreateRejectsMissingRequiredAndBlank`, `TestCreateRejectsInvalidEnum`, `TestCreateIgnoresServerOwnedFields` | Unknown fields are a 422 rather than silent data loss; server-owned fields are ignored. | +| Idempotent delete | `TestDeleteIsIdempotent` | 200 whether or not a row matched, as §12.7 requires. | +| Seed fidelity | `TestSeedMatchesFixtureCounts`, `TestSeedPreservesSourceValues`, `TestSeedRegressionAnchors` | The database is compared against the fixture field-by-field, not against numbers typed into a test. | +| Seed convergence | `TestSeedIsIdempotent`, `TestReseedOnALaterDayLeavesNoStaleShifts`, `TestReseedIsConvergentAcrossAWeek`, `TestPruneIsScopedToTheSeededOrganization` | Re-seeding converges the rolling shift window without touching other organizations or API-created rows. | +| Password and session | `TestDefaultPasswordParamsMeetOWASP`, `TestSessionCannotOutliveItsAbsoluteDeadline`, `TestDatabaseRefusesARawToken`, `TestUserDeletionCascadesToSessions` | The cost parameters, the absolute cap, the CHECK that refuses a raw token, and the cascade all hold at the database level. | +| Authentication behaviour | `TestLoginFailuresAreIndistinguishable`, `TestLoginSetsHardenedCookieAndNeverReturnsTheToken`, `TestCookieIsSecureOutsideDevelopment`, `TestSessionSlidesButNotForever`, `TestSuspendedUserIsRejectedMidSession`, `TestLoginRateLimitIsPerEmailAndPerAddress` | Enumeration resistance, cookie hardening, sliding-with-a-ceiling, mid-session suspension, and both limiter dimensions. | +| Authorization | `TestRoleMatrix`, `TestTalentSeesOnlyTheirOwnRecords`, `TestCrossOrganizationIsolation`, `TestForbiddenVersusNotFound`, `TestServerOwnedIdentityCannotBeSupplied`, `TestTalentCannotInterviewForAnotherApplication`, `TestTalentSeesOnlyActivePostings`, `TestExpiredSessionIsRefusedBeforeRoleCheck` | The full matrix, the row predicates, the 403/404 distinction, and that identity cannot be supplied by the request. | +| Policy invariants | `TestEveryResourceHasAPolicy`, `TestDerivedColumnsAreReadOnlyOrTalentScoped`, `TestOnlyTalentIsRowScoped`, `TestTalentScopesNameRealColumns` | Regenerating the descriptors cannot silently drop a rule, and a scope cannot name a column that does not exist. | +| Schema invariants | `TestDefinitionTablesShape`, `TestUniquenessPerTier`, `TestForeignKeysAndDeleteBehaviour`, `TestMigrationAddsExactlyTwoTables`, `TestMigration000005IsReversible`, `TestEveryMigrationHasADownFile` | The definition schema is asserted against the live database, including the deferred tables that must **not** exist. | +| Parser conformance | `TestFrontmatterTreeParity`, `TestAcceptanceParity`, `TestRejectionMessageParity`, `TestProjectionParity`, `TestAdversarialCoverage`, `TestDeferredBlocksAreReported` | Go matches the captured JS output over 37 shipped definitions and 132 adversarial cases, including the exact rejection wording. | +| Parser anti-regression | `TestMutationsWouldBeCaught`, `TestParsingDoesNotMutateSource`, `TestNormalizationIsNotStorage` | Plausible parser mistakes are caught by real definitions changing meaning, and normalization never rewrites what is stored. | +| Runtime | `TestRuntime_StatusEligibility`, `TestRuntime_DependencyResolution`, `TestRuntime_PersonalSkillShadowing`, `TestRuntime_ExecutorBoundary`, `TestRuntime_MalformedMarkdown` | Eligibility, resolution across tenants and tiers, shadowing precedence, and that the boundary refuses rather than pretends. | +| Health | `TestHealthEndpoint`, `TestHealthLeaksNoInfrastructure`, `TestHealthUnavailableSaysNothingAboutWhy` | The public body carries a verdict and no reconnaissance. | +| CORS | `TestCORSAllowsConfiguredOrigin`, `TestCORSPreflight`, `TestCORSRefusesUnknownOrigin`, `TestCORSIgnoresRequestsWithoutOrigin`, `TestCORSOffByDefault` | Exact matching, and that an origin-less caller is unaffected. | + +--- + +## 15. Frontend Integration + +``` +React (krow-demo, a separate repository — not part of this checkout) + | +base44Client.js the transport seam; the one frontend file allowed to change + | +HTTP client Not verified from current repository + | +Go API /api/v1, JSON, session cookie + | +PostgreSQL +``` + +### What is verifiable from this repository + +- **API base path** — `/api/v1`. The server binds `HTTP_HOST:HTTP_PORT`, defaulting + to `127.0.0.1:8080`. +- **CORS** — an exact-match allowlist from `HTTP_CORS_ORIGINS`. In development with + the variable unset, the defaults are `http://localhost:5173`, + `http://127.0.0.1:5173` and the `vite preview` port. Unset outside development the + allowlist is empty and the middleware is not installed, which is same-origin only. +- **Transport compatibility** — field names are snake_case and identical to the + frontend's; ids are opaque strings to the client; the response envelope is one + `.data` unwrap; `meta.total` exists because the frontend's bare array shape had + nowhere to put it. +- **Preserved client interface** — the contract's acceptance criterion is that + swapping the transport inside `base44Client.js`, and changing no other frontend + file, leaves the application behaving identically. The contract states that if + implementing it required editing `krowHooks.js`, a page or a component, the + contract was wrong and got fixed first. +- **Connected entities** — the 13 resources with registered routes, plus `/me` and + `/me/preferences`. +- **The `{ persisted }` return shape** — `auth.updatePreferences()` returns + `{ user, persisted, error }` rather than a bare user, because a swallowed + `QuotaExceededError` used to lose account-authored skills silently. Over HTTP a + failed write is already a non-2xx, so the shim synthesises the success shape. + +### What is not verifiable here + +- `httpClient.js` — **Not verified from current repository.** The string does not + appear anywhere in this checkout. +- Any `VITE_API_*` environment variable — **Not verified from current repository.** +- The current state of the frontend's own tests — **Not verified from current + repository.** The frontend repository is not part of this checkout. + +### Known integration mismatches + +Recorded in `docs/api-contract.md` §12 and still accurate against the current code: + +| # | Mismatch | +| --- | --- | +| 12.1 | Four flows write several records in a client-side loop with no transaction and no rollback (`useHireCandidate`, `useAssignWorkers`, `useScreenAllCandidates`, `useSubmitChallenge`). A failure halfway leaves the database inconsistent. `useAssignWorkers` issues 3n sequential round-trips for n workers. | +| 12.2 | Collection caps truncate silently. `worker-profiles` caps at 500 sorted by `-krow_score`, so profile 501 is invisible. `meta.truncated` exists to make this fixable; nothing consumes it yet. | +| 12.3 | All aggregation — funnels, KPI tiles, charts, attendance rollups, overtime — computes in the browser over the capped arrays. | +| 12.4 | Search is browser-side substring matching over a concatenated string. No endpoint implements search. | +| 12.5 | Email matching is case-insensitive server-side (`citext`) where `store.js` used `===`. Deliberate and documented as safe. | +| 12.7 | `DELETE` on a missing record returns 200, because the frontend deletes inside loops without checking and a 404 would surface an error toast where none appears today. | +| 12.8 | `InvokeLLM` and `UploadFile` remain local to the browser. Nine AI workflows run deterministically client-side. No endpoint replaces them. | +| §13.4 | `updated_date` is now always populated where `store.js` returned `undefined`; one visible effect is that `TalentDetailModal.jsx:66` shows the seeded date rather than today. | + +--- + +## 16. Important Architectural Decisions + +| Decision | Why | Current implementation | Future direction | +| --- | --- | --- | --- | +| **Go for the API** | A single static binary, a strong standard-library HTTP server, and explicit error handling for a service whose main job is correctness at a boundary | **Current.** Go 1.27, `net/http` only, three direct dependencies | — | +| **PostgreSQL, no ORM** | The contract's semantics (`NULLS LAST` both ways, the `, id` tiebreaker, shallow PATCH, idempotent DELETE) are easier to guarantee in SQL written once than to coax out of a mapper | **Current.** `internal/repo` builds every statement from a descriptor | — | +| **Descriptors generated from the live schema** | Column names, types, enum values and nullability then cannot drift from the migrations | **Current.** `make gen-resources` → `resources_gen.go` | — | +| **Policy hand-written, kept apart from descriptors** | Regenerating descriptors must never silently drop an access rule, and a new column must never grant anyone anything by accident | **Current.** `internal/domain/policy.go`, enforced by `TestEveryResourceHasAPolicy` | — | +| **One shared query builder, not fourteen repositories** | The awkward semantics get implemented once and apply identically everywhere | **Current.** `internal/repo/repo.go` | — | +| **Migrations are the source of truth** | If the database and `migrations/` disagree, `migrations/` is right; an applied migration is immutable | **Current.** Five pairs; nothing in `go-api/` issues DDL | Expand-migrate-contract as a discrete CI deploy step before the binary rolls out | +| **`org_id` on every tenant-scoped table** | Tenancy has to be a predicate on the row, not a convention in the caller | **Current.** Server-owned on every resource; `NOT NULL` even on personal definitions | — | +| **Ownership as a SQL predicate, not a filter** | `count(*)` runs over the same clause, so totals are correct and unauthorized rows are never fetched | **Current.** `builder.ownership` | — | +| **403 for role, 404 for row** | A caller must not be able to distinguish "exists and is not yours" from "does not exist" | **Current.** Role gate in the handler before any query | — | +| **API contract derived from call sites** | A table existing is never a reason for an endpoint to exist | **Current.** `docs/api-contract.md`; `badges` has no endpoints | Multi-record transactional endpoints would require touching frontend hooks, so they were deliberately left out of v1 | +| **The transport boundary is one frontend file** | The whole migration is affordable only if nothing above `base44Client.js` changes | **Current.** Envelope, snake_case fields, opaque ids, per-endpoint defaults | — | +| **Sessions are rows; only SHA-256 is stored** | A dump of the sessions table must not be replayable as a login | **Current.** Opaque 256-bit tokens, HttpOnly cookie, CHECK-pinned hash format | — | +| **Two expiries per session** | A sliding window alone can be slid forever | **Current.** 12h/24h and 30d/90d | — | +| **`users.role` is the sole authority** | `account_type` is user-writable through `PATCH /me` and cannot be an authorization field | **Current.** Documented in contract §9A.1 | — | +| **Verbatim Markdown as the authoritative artefact** | A definition must survive a round trip to a `.md` file on disk unchanged; every column is a cache of it | **Current.** `markdown` stored byte-for-byte; projections recomputed on every write | — | +| **JS/Go parser parity enforced by replay** | Two parsers that disagree fail silently in both directions | **Current.** `testdata/oracle.json` captured from the real frontend module graph; 37 corpus + 132 adversarial cases | Regenerate the oracle after any change to `src/lib/skills` or `src/lib/agents` | +| **No YAML library** | A general parser accepts a larger language than the frontend does; every extra construct is a definition the editor cannot read | **Current.** Hand-ported subset in `yaml.go` | — | +| **Personal vs organization definitions, two partial unique indexes** | Shadowing by id is the point, so the id must be unique per tier and never globally | **Current.** `..._personal_key` and `..._org_key` | — | +| **No skill versioning** | The frontend has no notion of a skill version and cannot set one; inventing it would create a column forever equal to 1 | **Current.** `skill_definitions` has no `version` column, asserted by `TestSkillStatusAndNoVersion` | Would require an explicit architecture decision | +| **Two definition tables, not one** | Agents and skills do not share a lifecycle; one table would need a union CHECK permitting nonsense states | **Current.** `agent_definitions`, `skill_definitions` | — | +| **`permissions:` parsed but not enforced** | Its semantics are not defined yet, and a half-enforced permission model is worse than none | **Current.** Parsed onto `Agent.Permissions`; no `definition_permissions` table | Define the semantics before storing them | +| **Runtime execution boundary before any executor** | Loading, scoping, eligibility and resolution are worth getting right independently of what eventually executes | **Current.** `AgentExecutor`/`SkillExecutor` interfaces; `UnavailableExecutor` refuses | Real executors attach here | +| **Health endpoint says the verdict, not the reasoning** | An unauthenticated endpoint is a public document, not an operator console | **Current.** One status field; full detail in the server log | — | +| **Firebase Auth** | — | **Future direction.** The repository contains no reference to Firebase; the current implementation is server-side sessions with argon2id | Would replace or front the current credential path | +| **Google Pub/Sub** | — | **Future direction.** No reference in the repository | — | +| **GCS-compatible object storage** | — | **Future direction.** No reference in the repository; `infrastructure/README.md` names MinIO as a "later" candidate | — | +| **Docker deployment** | — | **Future direction.** `infrastructure/` contains only a README; `Dockerfile.api` and `Dockerfile.owliver` are listed there as later work | — | +| **Redis** | Shared state for rate limiting across instances | **Future direction.** `ratelimit.go` names Redis or the database as the seam; nothing is wired | Needed before multi-instance deployment for the limiter to mean anything | +| **NATS is not part of the target architecture** | — | **Future direction / stated rule.** Note the discrepancy: `infrastructure/README.md` currently lists NATS as a later docker-compose candidate. See §17. | Remove NATS from that table if the rule stands | +| **RAG / pgvector** | — | **Future direction.** `pgvector` is not installed in the local database and is named only once, in `infrastructure/README.md` | Introduce when the architecture calls for it | + +--- + +## 17. Known Issues & Current Limitations + +Everything below was checked against the current repository. Items the request asked +about that could not be verified are marked as such rather than guessed at. + +### 17.1 Documentation is behind the code + +**Issue.** `README.md` describes the repository as being at an earlier stage than the +code is. It states "Authorization — which roles may do what — is Phase 3D and is not +implemented", "Authorization is **not** implemented. `users.role` is carried on the +identity and consulted nowhere: any signed-in user reaches every endpoint", "38 +endpoints" and "55 tests". + +**Cause.** Authorization, the definition tables, the CRUD surface and the runtime all +landed after the README was last revised. + +**Impact.** A reader trusting the README would conclude that any signed-in user +reaches every endpoint, which is not what the code does. + +**Current behaviour.** `internal/domain/policy.go`, `Server.authorize`, +`builder.ownership` and `internal/httpserver/rbac_test.go` (730 lines) all exist and +run. 51 routes are registered. 175 test functions run. +`docs/api-contract.md` §9A *is* current and documents the authorization contract +accurately. + +**Possible future handling.** Revise `README.md` against the code. + +The same staleness appears in two source comments: + +- `internal/httpserver/server.go` package documentation: "Authorization is NOT here. + A signed-in user reaches every endpoint they could reach before". +- `internal/authctx/authctx.go`: "It is NOT consulted anywhere in Phase 3C: + authentication only" — `Identity.Role` is now consulted by `Server.authorize`, + `builder.ownership`, `guardInsert` and `service/definitions.go`. + +### 17.2 The definitions endpoints are not in the API contract + +**Issue.** `docs/api-contract.md` calls itself "the frozen client contract" and +contains **zero** occurrences of `agent-definitions` or `skill-definitions`, yet ten +routes are registered. + +**Cause.** The contract was revised for authorization (§9A) but not for the +definitions surface. + +**Impact.** A frontend engineer working from the contract would not know these +endpoints exist, and their request/response shapes, filters and error codes are +documented only in Go source and tests. + +**Current behaviour.** The endpoints work and are covered by 8 test functions. + +**Possible future handling.** Add a definitions section to the contract. + +### 17.3 A referenced document does not exist + +**Issue.** `internal/definition/definition.go` states that backend-only rejection +rules "each one is listed in docs/phase-4d-parser-contract.md", and +`internal/definition/skill.go` refers to "the contract document". `docs/` contains +only `api-contract.md` and this file. + +**Impact.** The two `BackendOnly` rules are discoverable only by reading +`ValidateAgent` and `ValidateSkill`. + +### 17.4 The definitions surface bypasses the policy table + +**Issue.** The ten definition handlers do not call `Server.authorize` and the +`policies` map has no entry for `agent-definitions` or `skill-definitions`. Role +checks are instead written inline in `internal/service/definitions.go` — the same +`if !known || role == domain.RoleTalent { return nil, domain.Forbidden() }` block +appears eight times. + +**Cause.** Definitions are not `domain.Resource` values, so the descriptor-and-policy +machinery does not reach them. + +**Impact.** The deny-by-default guarantee — enforced by `TestEveryResourceHasAPolicy` +— does not extend to the newest ten endpoints. A future definition-related endpoint +added without its inline check would be reachable by any signed-in user, and no test +would notice. + +**Current behaviour.** Tenancy and ownership *are* enforced, in the repository +predicates, for every definition read and write. The role rule that exists — +organization-visibility definitions are writable by admin and employer only — is +applied on create, update and delete, and is covered by tests. + +**Possible future handling.** Bring definitions under the policy table, or add a test +that asserts every definition route has an explicit role check. + +### 17.5 The runtime package is not reachable from the running service + +**Issue.** No code outside `internal/runtime` and its own test file references the +package. There is no HTTP route, no command, and no service that constructs an +`Engine`. + +**Impact.** The runtime's loading, scoping, eligibility and resolution logic is +exercised only by its tests. Nothing a deployed binary does touches it. + +**Current behaviour.** As stated in §7: runtime execution is an internal backend +boundary and no public runtime execution endpoint is implemented. + +### 17.6 Cycle detection in dependency resolution is unreachable + +**Issue.** `Loader.ResolveAgentDependencies` maintains an `inProgress` map and returns +`ErrCircularDependency`, but sets `inProgress[skillID] = true` and back to `false` +within the same loop iteration, and never recurses — skills do not resolve skills. +The error is therefore not reachable, and no test covers it. + +**Cause.** The guard is shaped for a recursive resolver; the resolver is one level +deep. + +**Impact.** A genuine cycle cannot exist in the current model (an agent names skills; +skills name nothing), so nothing is currently mis-handled. The code implies a +protection that is not active. + +**Possible future handling.** Either make resolution recursive, at which point the +guard becomes live and needs a test, or remove it. + +### 17.7 Unchecked type assertions in the runtime loader + +**Issue.** `internal/runtime/loader.go` lines 67, 72, 129 and 133 perform +`rec["id"].(string)` and `rec["visibility"].(string)` without the comma-ok form. Every +other map access in the codebase, including `internal/repo/repo.go:346`, uses the +defensive form. + +**Impact.** A nil or non-string value would panic. The panic would be caught by +`recoverer` and become a logged 500 — but only if the runtime were reachable over +HTTP, which it is not. + +**Current behaviour.** The repository projects both columns as `::text`, so the +assertion holds in practice. + +### 17.8 CORS does not permit credentials + +**Issue.** `internal/httpserver/cors.go` never sets +`Access-Control-Allow-Credentials: true`. Its own comment anticipates this — "so +adding credentials later does not require rewriting this" — but the header was not +added when cookie authentication landed. + +**Impact.** A browser at `http://localhost:5173` calling the API at +`http://127.0.0.1:8080` with `credentials: 'include'` will have the response blocked +by the browser, even though the server answered correctly. This affects exactly the +cross-origin development setup the CORS middleware exists for. A same-origin +deployment is unaffected. + +**Current behaviour.** Five CORS tests exist and pass; none asserts anything about +credentials. + +**Possible future handling.** Set the header for allowlisted origins, and add a test. + +### 17.9 Login rate limiting does not survive scale-out + +Covered in §13 under limitations: per-process, in-memory, `RemoteAddr`-based, pruned +rather than capped. The source documents all three limitations itself. **Impact:** +the effective limit multiplies by instance count, resets on restart, and degrades to +a global budget behind a reverse proxy. **Possible future handling:** move the +limiter behind shared state before deploying more than one instance. + +### 17.10 The repository has no commits + +**Issue.** `git log` reports `fatal: your current branch 'main' does not have any +commits yet`. Every file is untracked. + +**Impact.** There is no history, no recovery point, no blame, and no record of when +any of the work described in §2 happened. The chronology in this document was +reconstructed from migration headers, package documentation and the contract, not +from version control. + +**Note.** This document does not change that; no commit was made. + +### 17.11 A minor documentation defect in the source + +`internal/httpserver/api.go` — the doc comment describing `decodeBody` sits +immediately above `decodeInto`, so `decodeInto` carries two doc comments and +`decodeBody` carries none. + +### 17.12 Badge endpoint mismatch — verified + +**Issue.** The `badges` table exists and is seeded with 4 records, but there is no +endpoint for it. + +**Cause.** Recorded in contract §2: the frontend's `useBadges` hook has **zero +consumers**, and every badge the UI renders comes from +`worker_profiles.earned_badges`. The table exists; nothing reads it. + +**Impact.** A frontend `Badge.list()` call would receive a 404. `README.md` states +that this call "has 404ed since Phase 2C". + +**Current behaviour.** `badges` declares `Ops: 0`, so no route is registered, and its +policy is written out as an explicit empty policy so the resource is deliberately +closed rather than merely forgotten. + +**Possible future handling.** Add the endpoint if a consumer appears, or drop the +table. + +### 17.13 Job Posting 422 — mechanism verified, no specific defect recorded + +**Issue as raised.** A "Job Posting 422 validation issue". + +**What the repository shows.** No artifact — comment, test, TODO or contract note — +records a specific job-posting defect. *Not verified from current repository.* + +**What can be stated factually.** `POST /api/v1/job-postings` answers 422 in exactly +the documented cases, and two of them are easy to hit from a client that was written +against the in-browser store: + +1. **An unknown body field.** Contract §13.5 made unknown fields a 422 rather than a + silent drop, deliberately. Any field the frontend sends that has no column + produces `validation_failed` with the field named in `details`. +2. **An invalid enum value.** `job_postings` has three enum columns — + `english_required`, `status` and `priority` — and a value outside the declared set + is a 422 naming the permitted values. + +`title` is the only column marked `Required` on this resource, so a missing-required +422 would name `title` specifically. + +**Possible future handling.** If the symptom is reproducible, the `details` map in +the 422 body names the offending field, which is enough to decide whether the column +is absent from the schema (as `interview_id`, `training_outline` and +`score_breakdown` once were) or the value is wrong. + +### 17.14 Shift snapshot / date behaviour — verified + +Not a defect; a documented design constraint. Shift records are generated rather than +snapshotted because their dates are anchored to *now*, and the frontend windows every +collection on `created_date`. A frozen snapshot would read as permanently empty a +fortnight later. The consequence, recorded as U1 in the contract, is that attendance +and overtime will be **empty in any real deployment** until a rostering source exists. +Re-seeding on a later day prunes stale rows to keep the rolling window convergent. + +### 17.15 `resetDemoData` — not present + +The string `resetDemoData` does not appear anywhere in this repository. *Not verified +from current repository.* It is presumably a frontend concern. + +### 17.16 Frontend test drift — not verifiable here + +The frontend repository is not part of this checkout. *Not verified from current +repository.* + +### 17.17 NATS appears in the repository despite the stated direction + +**Issue.** The direction stated for this document is that NATS is not part of the +target architecture. `infrastructure/README.md` currently lists "Redis, NATS, MinIO" +as candidates for a later `docker-compose.dev.yml`, and `README.md` mentions NATS +among the things that do not exist yet. + +**Impact.** A reader of `infrastructure/README.md` would take NATS to be planned. + +**Possible future handling.** Amend that table if the rule stands. Nothing was changed +here. + +### 17.18 Deliberate contract behaviours that read as defects + +These are recorded in `docs/api-contract.md` §12 with reasons and are not bugs: +multi-record writes are not transactional (§12.1); collection caps truncate silently +(§12.2); all aggregation is client-side (§12.3); search is browser-side (§12.4); email +comparison is case-insensitive server-side (§12.5); `DELETE` on a missing record +returns 200 (§12.7); LLM and file-upload integrations remain in the browser (§12.8). + +--- + +## 18. Future Technical Direction + +**Nothing in this section is implemented.** Each item is labelled with what the +repository actually says about it, so the two are never confused. + +### Agent Runtime / Owliver + +The intended direction is a Python "Owliver" service alongside the Go API, with a +subagent bridge and orchestration, and real execution behind the existing +`AgentExecutor` / `SkillExecutor` interfaces. + +*What the repository says:* `README.md` states "a Python Owliver service to follow" +and "No Owliver". `internal/runtime/executor.go` names "real AI / Owliver / LangGraph +executors" as what would replace `UnavailableExecutor`. +`infrastructure/README.md` lists `Dockerfile.owliver` as later work. No Python source, +no bridge and no orchestration code exists here. + +### AI Runtime + +An LLM provider abstraction, with an appropriate provider per workload. + +*What the repository says:* nothing. No provider name — OpenAI, Anthropic, Gemini, +Vertex — appears anywhere in this checkout. *Not verified from current repository.* +The attachment point is the two executor interfaces. + +### RAG + +`pgvector`, embeddings and retrieval over an agent's knowledge corpus. + +*What the repository says:* `pgvector` is named exactly once, in +`infrastructure/README.md`, as part of a later dev compose file. It is **not +installed** in the local database (only `citext` and `plpgsql` are). Migration 000005 +explicitly declines to create an `agent_knowledge` table because "no corpus exists". +Contract §11 lists RAG and embeddings as D7–D10, "none touch v1". + +### Tools + +FastMCP and a tool-execution layer. + +*What the repository says:* nothing. Neither "MCP" nor "FastMCP" appears anywhere. +*Not verified from current repository.* Note that `internal/definition/yaml.go` +states as a design property that "there is no code path from a definition to +execution of any kind" — introducing tool execution changes that property +deliberately and should be done knowingly. + +### Infrastructure + +- **Google Pub/Sub** — no reference in the repository. *Not verified from current + repository.* +- **GCS-compatible object storage** — no reference. `infrastructure/README.md` names + MinIO as a later compose candidate. +- **Docker** — `infrastructure/` contains only a README, deliberately empty. It lists + `docker-compose.dev.yml`, `Dockerfile.api`, `Dockerfile.owliver` and + `otel-collector.yaml` as later work, with the stated reason that adding any of them + before its phase would be speculative. +- **Redis** — named in `ratelimit.go` as the seam for shared limiter state, and in + `infrastructure/README.md` as a later compose candidate. Nothing is wired. +- **Observability** — `otel-collector.yaml` is listed as later work. Today there is + structured JSON logging via `log/slog` and nothing else: no metrics, no tracing, no + exporter. +- **Production hardening** — no TLS termination, secret management, key rotation, + backup policy or deployment manifest exists in this repository. + +### NATS + +**NATS is not part of the target architecture.** Note that `infrastructure/README.md` +currently lists it as a later candidate; see §17.17. + +### Nearest-term work implied by the code itself + +Not a roadmap, but the things the repository points at: + +- Multi-record transactional endpoints (`POST /job-applications/{id}/hire`, + `POST /job-postings/{id}/assignments`), which the contract defers because they + require touching frontend hooks. +- Server-side aggregation, which contract §12.3 identifies as the first thing that + breaks as data grows — `shift_records` at 500 rows. +- Consuming `meta.truncated`, which exists so §12.2 is fixable without a contract + change. +- Defining the semantics of the `permissions:` block before storing or enforcing it. +- Shared state for the login limiter before deploying more than one instance. + +--- + +## 19. What We Have Built So Far + +For a team audience, in plain terms. + +**Where we started.** A React demo whose data lived in a browser tab. `store.js` held +arrays, `seed.js` filled them at boot, and every hook called through one file: +`base44Client.js`. Nothing survived a refresh, everyone saw the same data, and no +rule existed that the client did not enforce on itself. + +**Where we are.** A Go service in front of PostgreSQL that the same React app talks to +through the same one file. + +| What we built | What it is practically worth | +| --- | --- | +| **A Go API over PostgreSQL** | Data survives. Two people see the same records. A query that takes 200 ms in the browser over 500 rows takes a millisecond in an index. | +| **A written contract derived from call sites** | Nobody had to guess what the frontend needed, and no endpoint exists that nothing calls. The frontend migration cost exactly one file. | +| **Migrations as the source of truth** | The schema has a history, a rollback, and a review surface. No table has ever been created by hand. | +| **Generated resource descriptors** | Column names, types and enums cannot drift from the database, because they are read out of it. | +| **A seeder that runs the frontend's own seed** | The demo dataset in PostgreSQL is the demo dataset the frontend ships, not a transcription of it. Re-seeding is safe and converges. | +| **Session authentication** | There is a real identity behind every request, held in an HttpOnly cookie the page cannot read, backed by a row we can revoke. | +| **Role-based authorization with deny-by-default** | Who may do what is written down in one table, and a resource nobody has written a rule for is unreachable rather than open. | +| **Tenant and ownership isolation in SQL** | A row from another organization, or another worker, is never fetched — so it cannot leak through a count, a total, or a bug in a later loop. | +| **Agent and Skill authoring in the database** | Authored definitions moved out of a jsonb blob in user preferences into real tables with an owner, a tenant, a size bound and a query surface. | +| **A Go parser that provably matches the JS one** | An author sees the same acceptance, the same rejection and the same wording in the editor and from the API — asserted against the real frontend parser's captured output over 37 shipped definitions and 132 adversarial cases. | +| **A runtime boundary** | Loading, tenant scoping, status eligibility and dependency resolution are written and tested, so when execution arrives it plugs into an interface rather than reopening those questions. | +| **A test suite that runs against a real database** | 175 test functions, 920 test entries, a disposable migrated-and-seeded database per test process, and ordering guarantees verified to fail when removed. | + +**What is deliberately not built yet.** Real AI execution. Any public runtime +endpoint. Server-side aggregation. Transactional multi-record flows. RAG. Tooling. +Anything in `infrastructure/`. + +--- + +## 20. Current Backend Snapshot + +**Current Database:** PostgreSQL 18.6 locally; one schema (`public`); five migration +pairs, applied version 5, not dirty; 20 application tables plus `schema_migrations`; +16 enum types; extensions `citext` and `plpgsql`; UUID primary keys with a nullable +unique `legacy_id`; `timestamptz` throughout. + +**Current API:** Go 1.27, `net/http` only, base path `/api/v1`, 51 registered routes +(34 resource + 4 `/me` + 2 auth + 10 definitions + `/health`). JSON envelope with +`data` and, on collections, `meta`. Two filter operators, `NULLS LAST` in both +directions with an `id` tiebreaker, `limit`/`offset` with per-endpoint defaults and a +1000 cap, and nine error codes. + +**Current Authentication:** email + password with argon2id at OWASP parameters; +opaque 256-bit session tokens stored only as SHA-256; HttpOnly, `SameSite=Lax` +cookies with `Secure` outside development; sliding expiry with an absolute ceiling +(12h/24h, or 30d/90d with remember-me); a 15-minute expired-session sweeper; failed +attempts limited per email and per address; uniform 401s with decoy hashing to resist +enumeration. Passwords enter only through `cmd/setpassword`. + +**Current RBAC:** three roles from `users.role` — `admin`, `employer`, `talent`. +Deny-by-default policy table keyed by URL path. Role gate in the handler answering +403 before any query. Eight server-owned identity columns. `PATCH /me` writes only +`full_name` and `account_type`. + +**Current Tenant Model:** one organization per user, taken from the user's row and +never from the request. `org_id` predicate on every read and write. `courses` and +`learning_paths` also match `org_id IS NULL`, the shared platform library. Talent +callers carry a second ownership predicate in the same `WHERE` clause; a row outside +either predicate answers 404. + +**Current Agent System:** Markdown with YAML frontmatter, stored verbatim in +`agent_definitions`; six projected columns; `draft`/`published`/`archived` plus a +monotonic integer version; personal and organization tiers with per-tier uniqueness; +full CRUD over five routes. + +**Current Skill System:** the same shape in `skill_definitions`, minus version; +`active`/`inactive`; full CRUD over five routes. `ui:` and `owliver:` frontmatter +blocks are recorded as deferred rather than validated. + +**Current Runtime:** an internal boundary. Loads by UUID or `definition_id` within +the caller's tenant and ownership scope, re-parses and re-validates the stored +Markdown, applies status eligibility, resolves an agent's skill dependencies with +personal-over-organization shadowing and deduplication, and dispatches to an executor +interface. The only executor implementation refuses execution. No HTTP route reaches +it. + +**Current Frontend Integration:** the frontend repository is separate and unmodified. +The seam is `base44Client.js`; the contract's acceptance criterion is that swapping +the transport inside it changes nothing above it. CORS allowlist defaults to the Vite +dev server on both hostnames in development, and to empty elsewhere. + +**Current Testing:** 175 test functions, 920 test entries executed, no failures and +none skipped on a machine with PostgreSQL reachable. `gofmt` reports no files, +`go vet` reports no diagnostics, `go build ./...` builds. Database-backed tests use a +disposable `krow_backend_autotest_` database per test process. + +**Current Known Limitations:** no AI execution and no public runtime endpoint; login +rate limiting is per-process and in-memory; CORS does not permit credentials; +definitions endpoints bypass the policy table; the runtime package is unreachable +from the running service; `README.md` and two source comments are behind the code; +the definitions endpoints are absent from the API contract; attendance data is empty +without a rostering source; the repository has no commits. + +**Current Development Focus:** the most recent work in the tree is the definition +system — schema, parser conformance, CRUD — and the runtime boundary that sits on top +of it. The open seams the code itself points at are real executors behind the +executor interfaces, and a public surface for the runtime if one is wanted. + +--- + +## 21. Critical Engineering Rules + +These are the invariants the current code depends on. Breaking any of them silently +is how this codebase would stop being trustworthy. + +1. **Do not casually modify migrations.** Once a migration has run anywhere other + than your own machine it is immutable. Change it and every database that already + applied it diverges from every one that has not. Write the next one instead — + that is exactly what 000002 and 000003 are. +2. **Do not bypass tenant scoping.** `org_id` belongs in the `WHERE` clause of every + read and write. It is not a filter applied afterwards, and it is not optional on + personal rows. +3. **Do not trust a client-provided `org_id`.** It comes from the user's row, which + comes from the session row, which comes from a cookie value the client cannot + forge without already holding it. +4. **Do not trust a client-provided `owner_user_id`,** or any of the eight + server-owned identity columns. They are the columns every ownership rule rests on; + a caller who could set them could defeat the rule with the same request it + constrains. +5. **Do not bypass parser validation.** `ValidateAgent` / `ValidateSkill` before + `ParseAgent` / `ParseSkill` before persistence, on create and on any update that + touches `markdown`. +6. **Do not mutate stored Markdown.** The bytes in are the bytes stored. Normalization + reads; it never rewrites. Three tests assert this from three directions. +7. **Do not invent Agent or Skill fields.** Every field exists because the frontend + parser has it. A field the Go parser knows and the JS parser does not is a + definition the editor cannot read. +8. **Do not add skill versioning** without an explicit architecture decision. The + frontend has no notion of it and cannot set one. +9. **Do not bypass RBAC.** The role gate runs in the handler before any query, and + the row predicate runs in SQL. Both, every time. +10. **Do not expose cross-tenant resource existence.** 403 means "your role"; 404 + means "not yours or not there". Never a message that distinguishes the two. +11. **Do not introduce NATS.** +12. **Do not introduce RAG or `pgvector`** before the architecture calls for it. There + is no corpus, and migration 000005 declines to create a table for one. +13. **Do not introduce MCP or tool execution prematurely.** The parser's stated + property is that no code path leads from a definition to execution of any kind; + changing that should be a decision, not a side effect. +14. **Do not introduce Redis prematurely.** The limiter names it as the seam; wire it + when there is more than one instance, not before. +15. **Do not modify the frontend during backend-only work** unless it is explicitly + required. The whole transport migration is affordable because exactly one + frontend file changes. +16. **Do not fake AI execution.** `UnavailableExecutor` refuses honestly. A stub that + returns plausible text would be worse than one that returns an error. +17. **Do not silently change API contracts.** `docs/api-contract.md` is the client's + contract. Divergences from it have been reported and written down every time — + §13.4 and §13.5 exist for that reason. +18. **Do not let the descriptors and the policy table drift.** `resources_gen.go` is + generated; `policy.go` is hand-written. That separation is why regenerating one + cannot silently drop a rule in the other, and `TestEveryResourceHasAPolicy` is + what keeps it honest. +19. **Do not put reconnaissance in a public response.** `/health` answers the verdict; + the reasoning goes to the log. +20. **Do not log a password, a session token, or a password hash.** Nothing in + `internal/auth` logs at all, by design. + +--- + +## 22. Final Team Summary + +### What the team should know + +- **We started with a browser-only demo.** Data lived in arrays in a tab; there was + no identity, no tenancy, and no rule the client did not enforce on itself. +- **Go and PostgreSQL were introduced to move persistence, identity and enforcement + to a server** without rewriting the frontend. The acceptance criterion was written + down first: swapping the transport inside one frontend file must change nothing + above it. That held. +- **The architecture is a plain layered service** — handler, service, repository, pgx, + PostgreSQL — with no ORM and no web framework. Three direct Go dependencies. +- **The schema lives in `migrations/` and nowhere else.** Five migration pairs; + nothing in the Go code issues DDL. Resource descriptors are *generated* from the + live schema so they cannot drift. +- **The API contract was derived from frontend call sites, not designed.** An + operation with no call site gets no endpoint — which is why `badges` has none and + `DELETE /job-postings/{id}` answers 405. +- **Authentication is server-side sessions:** argon2id passwords, opaque 256-bit + tokens, only SHA-256 stored, HttpOnly cookies, sliding expiry with an absolute + ceiling, and login rate limiting per email and per address. +- **Authorization is deny-by-default.** Three roles from `users.role`. The role gate + answers 403 before any query; tenancy and ownership are SQL predicates, so a row + outside them answers 404 and cannot be distinguished from one that does not exist. +- **Agent and Skill definitions moved out of a jsonb preferences blob into real + tables** with an owner, a tenant, a size bound and a query surface. The Markdown is + the authoritative artefact; every column is derived from it and recomputed on write. +- **The Go parser provably matches the JavaScript one** — replayed against the real + frontend parser's captured output over 37 shipped definitions and 132 adversarial + cases, with mutation checks so the tests cannot pass against a broken parser. +- **The runtime boundary exists; execution does not.** Loading, tenant scoping, + status eligibility and dependency resolution with personal-over-organization + shadowing are written and tested. The only executor refuses. No HTTP route reaches + the runtime at all. +- **Verification is real:** 175 test functions, 920 test entries, against a disposable + migrated-and-seeded PostgreSQL database per test process. `gofmt`, `go vet` and + `go build` are clean. +- **The main current limitations** are: no AI execution and no runtime endpoint; + login rate limiting that does not survive scale-out; CORS that does not yet permit + credentials; ten definition endpoints that sit outside the policy table and outside + the written contract; documentation that is behind the code; and a repository with + no commits. +- **The direction** is real execution behind the existing executor interfaces + (Owliver / an LLM provider abstraction), then retrieval, then tooling, then + deployment infrastructure — none of which exists here today. **NATS is not part of + the target architecture**, and the one place in the repository that still names it + should be corrected. +- **The rules that must not be broken** are in §21. The two that matter most in daily + work: never trust a client-supplied identity or tenant, and never rewrite stored + Markdown. + +--- + +*Generated from the repository state on 2026-08-24. Every count, version and +behaviour above was read from the current checkout or from read-only queries against +the local development database. Nothing in the repository was modified to produce +this document.* diff --git a/docs/api-contract.md b/docs/api-contract.md new file mode 100644 index 0000000..c2cab4b --- /dev/null +++ b/docs/api-contract.md @@ -0,0 +1,1060 @@ +# Krow API Contract — v1 + +**Status: frozen for Phase 2C/2D implementation.** This document is derived +entirely from the Krow frontend repository (`krow-demo`) as it stands, and from +the Phase 1 schema in `migrations/000001_initial_schema.up.sql`. Nothing here is +aspirational: every endpoint exists because a call site exists, and every +semantic below was read out of `src/api/store.js` rather than designed. + +The acceptance criterion for Phase 2D is narrow and testable: + +> Replacing the transport inside `src/api/base44Client.js` — and changing no +> other frontend file — must leave the application behaving identically. + +If implementing this contract requires editing `krowHooks.js`, a page or a +component, the contract is wrong and gets fixed here first. + +--- + +## 1. API conventions + +| Aspect | Decision | +| --- | --- | +| Base path | `/api/v1` | +| Resource naming | kebab-case plural (`/job-postings`, `/worker-profiles`). Mass nouns stay singular: `/staff`, `/evidence`, `/user-activity`. | +| Identifiers | `uuid` in the path. See §1.2 on legacy ids. | +| Content type | `application/json; charset=utf-8` both ways | +| Field naming | **snake_case, identical to the frontend's field names.** No renaming, no camelCase conversion. The one exception is `/me/preferences` — see §9. | +| Dates | ISO 8601 with offset (`2026-08-21T10:04:53.402788Z`). `created_date` / `updated_date` keep those names. | +| Partial updates | `PATCH`, shallow merge. There is no `PUT`. | +| Reserved query params | `sort`, `limit`, `offset`. Every other query param is a field filter (§6). No current column collides with these three. | +| Auth | **None in v1.** Every endpoint is unauthenticated. §9 documents the shape auth will take without implementing it. | + +### 1.1 Why an envelope + +The frontend's entity methods return bare values today: `list()` and `filter()` +return an array, `get()`/`create()`/`update()` return an object, `delete()` +returns `{ id }`. The API nonetheless wraps responses in `{ "data": … }`. + +That is affordable precisely because `base44Client.js` is the one file allowed +to change: the shim unwraps with a single `.data` and the hooks above it never +see the envelope. What it buys is `meta.total`, which the bare shape has nowhere +to put — and the collection caps (§8) silently truncate today, which is a known +data-hiding bug the envelope makes fixable later without another contract change. + +### 1.2 uuid vs. the frontend's string ids + +Phase 1 gives every table a `uuid` primary key plus a nullable unique +`legacy_id text`. The frontend's existing ids (`jobposting_m1a2b3c001`, +`user_demo`) are `legacy_id` values, not uuids. + +**The API accepts and returns `id` as the uuid.** Path lookups resolve a uuid. +Records seeded from `src/api/seed.js` carry their original string in +`legacy_id`, which is returned as a field but is never the addressable id. + +This is invisible to the frontend, which treats ids as opaque strings and never +parses or constructs one — `makeId()` is called only inside `store.js`, which +the transport swap replaces. **Verified:** no page, hook or component builds an +id, pattern-matches one, or depends on its prefix. + +--- + +## 2. Entity endpoints + +Derived from the operation matrix in §10.1. **An operation with no call site +gets no endpoint.** `POST /job-postings/:id` does not exist because nothing in +the frontend deletes a job posting. + +### Live — reachable from a mounted route today + +| # | Method | Path | Purpose | +| --- | --- | --- | --- | +| 1 | `GET` | `/api/v1/job-postings` | List postings | +| 2 | `GET` | `/api/v1/job-postings/{id}` | One posting | +| 3 | `POST` | `/api/v1/job-postings` | Create a posting (incl. drafts) | +| 4 | `PATCH` | `/api/v1/job-postings/{id}` | Update a posting | +| 5 | `GET` | `/api/v1/job-applications` | List / filter applications | +| 6 | `POST` | `/api/v1/job-applications` | Create an application | +| 7 | `PATCH` | `/api/v1/job-applications/{id}` | Update — status, AI screening fields | +| 8 | `DELETE` | `/api/v1/job-applications/{id}` | Remove an application | +| 9 | `GET` | `/api/v1/ai-interviews` | List interviews | +| 10 | `POST` | `/api/v1/ai-interviews` | Record an interview result | +| 11 | `GET` | `/api/v1/staff` | List hires | +| 12 | `POST` | `/api/v1/staff` | Create a hire record | +| 13 | `PATCH` | `/api/v1/staff/{id}` | Update a hire (rating, endorsement) | +| 14 | `GET` | `/api/v1/worker-profiles` | List / filter profiles | +| 15 | `POST` | `/api/v1/worker-profiles` | Create a profile | +| 16 | `PATCH` | `/api/v1/worker-profiles/{id}` | Update a profile | +| 17 | `GET` | `/api/v1/courses` | List courses | +| 18 | `GET` | `/api/v1/courses/{id}` | One course | +| 19 | `POST` | `/api/v1/courses` | Create a course | +| 20 | `PATCH` | `/api/v1/courses/{id}` | Update a course | +| 21 | `GET` | `/api/v1/learning-paths` | List learning paths | +| 22 | `GET` | `/api/v1/role-categories` | List role categories | +| 23 | `POST` | `/api/v1/role-categories` | Create a role category | +| 24 | `GET` | `/api/v1/user-activity` | List / filter the activity log | +| 25 | `POST` | `/api/v1/user-activity` | Append an activity event | +| 26 | `GET` | `/api/v1/assignments` | List assignments | +| 27 | `POST` | `/api/v1/assignments` | Create an assignment | +| 28 | `GET` | `/api/v1/shift-records` | List shift records | +| 29 | `POST` | `/api/v1/evidence` | Submit challenge evidence | +| 30 | `PATCH` | `/api/v1/evidence/{id}` | Supervisor-verify evidence | +| 31 | `GET` | `/api/v1/me` | Current user | +| 32 | `PATCH` | `/api/v1/me` | Update current user | +| 33 | `GET` | `/api/v1/me/preferences` | Read preferences | +| 34 | `PATCH` | `/api/v1/me/preferences` | Merge preferences | + +### Unreachable today — included deliberately (D6) + +The calling code exists and compiles; only its route is unmounted. Excluding +these would leave the shim with methods that 404. See §11 (D6). + +| # | Method | Path | Sole consumer | +| --- | --- | --- | --- | +| 35 | `GET` | `/api/v1/certifications` | `CertificationManager.jsx` ← `pages/Positions.jsx` *(unmounted)*, `pages/KrowIdentity.jsx` *(unmounted)* | +| 36 | `POST` | `/api/v1/certifications` | `CertificationManager.jsx` | +| 37 | `DELETE` | `/api/v1/certifications/{id}` | `CertificationManager.jsx` | +| 38 | `GET` | `/api/v1/evidence` | `useEvidenceList` — **zero consumers**; included only so the shim's `Evidence.list/filter` resolves | + +### Not in v1 + +| Entity/op | Why | +| --- | --- | +| `GET /badges` | `useBadges` has **zero consumers**. Every badge the UI renders comes from `worker_profiles.earned_badges`. The table exists; nothing reads it. | +| `DELETE` on any resource but `job-applications` and `certifications` | No call site. | +| `POST /shift-records` | Nothing in the frontend creates one. See §11 (U1). | +| `GET /assignments/{id}`, `PATCH /assignments/{id}` | No call site — assignments are only listed and created. | +| `POST /ai-interviews/{id}` updates | No call site. | +| Multi-record transaction endpoints (hire, assign, screen-all, submit-challenge) | Deferred to Phase 3. See §12.1. | + +--- + +## 3. Request schemas + +### 3.1 Create — `POST /{resource}` + +Body is a **flat object of column values**, exactly the object the frontend +passes to `create()` today. No envelope on the request. + +The server supplies `id`, `created_date`, `updated_date` and `org_id`. Any of +those in the body is **ignored, not rejected** — see §7.4 for why that +distinction is load-bearing. + +Every column not supplied takes its schema default. Because the Phase 1 schema +declares `NOT NULL DEFAULT ''` / `'{}'` / `'[]'` on effectively every optional +column, a create with only the required fields succeeds and returns a fully +populated record — which is what `store.js` does today by returning whatever the +caller spread in. + +Required per resource (everything else optional): + +| Resource | Required | +| --- | --- | +| `job-postings` | `title` (non-blank) | +| `job-applications` | `job_posting_id`, `applicant_name` (non-blank), `email` | +| `ai-interviews` | `application_id`, `job_posting_id` | +| `staff` | `name` (non-blank), `email`, `hire_date` | +| `worker-profiles` | `full_name` (non-blank), `email` | +| `courses` | `title` (non-blank) | +| `role-categories` | `name` | +| `certifications` | `name` | +| `user-activity` | `event_type` (non-blank) | +| `assignments` | `job_posting_id`, `worker_email`, `starts_at` | +| `evidence` | `type`, `worker_email` | + +### 3.2 Update — `PATCH /{resource}/{id}` + +Body is a **partial** object. Only the keys present are written; every other +column is left alone. This mirrors `store.js`'s +`{ ...existing, ...data, updated_date: now }` exactly. + +- A key present with `null` sets the column to `NULL` (and is rejected if the + column is `NOT NULL`). +- A key absent is not touched. +- `id`, `created_date` and `org_id` in the body are ignored. +- `updated_date` is always set server-side to `now()`, overriding any supplied + value — `store.js` does the same by placing it after the spread. + +**There is no deep merge.** `store.js` shallow-merges, so `PATCH` with +`{"vetting_criteria": {"experience": 30}}` **replaces** the whole object rather +than merging into it. Preserving this matters: `useUpdateWorkerProfile` sends +whole recomputed arrays (`completed_courses`, `earned_badges`, `capabilities`), +and a deep merge would append instead of replace. + +### 3.3 Delete — `DELETE /{resource}/{id}` + +No body. + +--- + +## 4. Response schemas + +### 4.1 Single record + +```json +{ "data": { "id": "…", "title": "Bartender", "created_date": "…", … } } +``` + +Returned by `GET /{r}/{id}`, `POST /{r}`, `PATCH /{r}/{id}`. + +The record is **complete** — every column, including defaults the client never +sent. `store.js` returns a structured clone of the stored record, so the client +already relies on getting the whole thing back (`useHireCandidate` reads +`updated.status`; `useAssignWorkers` mutates `record.application_id` on the +returned assignment). + +### 4.2 Collection + +```json +{ + "data": [ { … }, { … } ], + "meta": { + "total": 214, + "limit": 100, + "offset": 0, + "returned": 100, + "truncated": true + } +} +``` + +`truncated` is `true` when `total > offset + returned`. Nothing reads it yet; +it exists so the silent-truncation bug (§12.2) is fixable without a contract +change. + +### 4.3 Delete + +```json +{ "data": { "id": "…" } } +``` + +Matching `store.js`'s `return { id }`. + +--- + +## 5. Error format + +```json +{ + "error": { + "code": "not_found", + "message": "JobPosting 7c9e… not found", + "details": {} + } +} +``` + +| Code | HTTP | When | +| --- | --- | --- | +| `unauthorized` | 401 | No valid session cookie, or a failed sign-in. See §9. | +| `forbidden` | 403 | Authenticated, but the caller's role does not permit the operation. See §9A.2. | +| `rate_limited` | 429 | Too many failed sign-in attempts. Carries `Retry-After`. | +| `not_found` | 404 | `GET`/`PATCH` on a missing id — including a row outside the caller's organization or, for talent, not their own | +| `validation_failed` | 422 | Body violates a column constraint. `details` maps field → reason. | +| `invalid_query` | 400 | Unknown filter field, malformed `sort`, non-integer `limit` | +| `conflict` | 409 | Unique violation (e.g. a second application for the same `(job_posting_id, email)`) | +| `internal` | 500 | Anything else. `message` is generic; the detail goes to the log with a request id. | + +### 5.1 The `message` field is load-bearing + +`store.js` throws `new Error(\`${name} ${id} not found\`)`, and the shim must +reproduce a thrown `Error` for a missing record because React Query's `isError` +path and several `.catch(() => …)` fallbacks depend on it. The shim converts any +non-2xx into `throw new Error(body.error.message)`. + +**Consequence for `not_found`:** the message must read +`" not found"` using the frontend's PascalCase entity name, not +the table name. Nothing parses it today, but it appears in console output and in +`PageNotFound`'s diagnostics, so keeping it identical costs nothing. + +--- + +## 6. Filtering semantics + +`store.js`: + +```js +function matches(record, query) { + return Object.entries(query).every(([key, want]) => { + const got = record?.[key]; + if (Array.isArray(want)) return want.includes(got); + return got === want; + }); +} +``` + +That is the entire filter language. It is reproduced exactly: + +| Behaviour | Rule | +| --- | --- | +| Combination | **AND** across every field. `.every()`. | +| Scalar | Strict equality. `WHERE col = $1` | +| Array | Membership. `WHERE col = ANY($1)`. Expressed as a repeated query param: `?status=applied&status=hired` | +| Operators | **None.** No `gt`, `lt`, `like`, `contains`, `between`. Adding one is a contract change. | +| Unknown field | `400 invalid_query`. `store.js` would return zero rows (every record's `undefined !== want`); a 400 is strictly better and cannot break a caller, because no caller sends an unknown field. | +| Array-valued columns | Not filterable. `got === want` is a reference comparison against a JS array and is always false, so `filter({ skills: 'Bartending' })` returns nothing today. The API must **not** silently improve this into a containment query. | +| `null` | Not expressible. No caller filters on null. | +| Empty query | Equivalent to `list()`. | +| Type coercion | Query params arrive as strings; the server coerces per column type before comparing. **Every filter in use today is on a `text`/`citext` column**, so this is currently a no-op — but `?ai_score=90` must compare as an integer, not a string. | + +### 6.1 Filters actually in use + +Only four call sites filter. Any other combination is untested and unsupported +in v1. + +| Endpoint | Filter | Call site | +| --- | --- | --- | +| `GET /job-applications` | `job_posting_id` | `krowHooks.js:85` | +| `GET /worker-profiles` | `email` | `krowHooks.js:554` | +| `GET /evidence` | `worker_email` | `krowHooks.js:633` *(hook has no consumers)* | +| `GET /user-activity` | `user_email` | `pages/Profile.jsx:28` *(unmounted — D6)* | + +`email` and `worker_email` map to `citext` columns, so matching is +case-insensitive **server-side** where `store.js` was case-sensitive. This is a +deliberate, safe divergence: `useAssignWorkers` already lowercases both sides +before comparing (`krowHooks.js:445`), so the frontend's own intent is +case-insensitive, and `worker_profiles` has a `UNIQUE (org_id, email)` that makes +a case-variant duplicate impossible to create. + +--- + +## 7. Sorting semantics + +```js +function applySort(records, sort) { + if (!sort) return records; + const desc = sort.startsWith('-'); + const field = desc ? sort.slice(1) : sort; + return [...records].sort((a, b) => { + const av = a?.[field]; const bv = b?.[field]; + if (av === bv) return 0; + if (av === undefined || av === null) return 1; + if (bv === undefined || bv === null) return -1; + const result = typeof av === 'number' && typeof bv === 'number' + ? av - bv : String(av).localeCompare(String(bv)); + return desc ? -result : result; + }); +} +``` + +| Rule | Behaviour | +| --- | --- | +| Syntax | `?sort=-created_date` — a leading `-` means descending, otherwise ascending. One field only. | +| Default | `-created_date` on every collection. Applied when `sort` is absent. | +| Empty string | `?sort=` returns rows in insertion order, unsorted. Preserved as "no `ORDER BY`". | +| Unknown field | `400 invalid_query`. | + +### 7.1 NULLs sort last in **both** directions + +The two null branches `return` before `desc` is applied, so a null is greater +than everything ascending *and* descending. This is not a bug to fix — it is +observable behaviour that the SQL must reproduce: + +```sql +ORDER BY col DESC NULLS LAST -- descending +ORDER BY col ASC NULLS LAST -- ascending, note: NOT the SQL default +``` + +PostgreSQL's default is `NULLS LAST` for `ASC` and `NULLS FIRST` for `DESC`, so +the descending case **must** be written explicitly or nulls will surface at the +top of every list where `store.js` put them at the bottom. + +### 7.2 Non-numeric comparison is lexicographic + +When either value is not a number, `store.js` compares +`String(av).localeCompare(String(bv))`. For the sorts actually in use this is +equivalent to a SQL text sort: + +- `-created_date` — ISO 8601 strings sort lexicographically the same as + chronologically. Sorting the `timestamptz` column directly is correct. +- `-ai_score`, `-krow_score` — both numeric on both sides, so `av - bv`. Sorting + the `int` column is correct. + +No other sort field is used, so no collation edge case is reachable in v1. + +### 7.3 Sort stability + +`Array.prototype.sort` is stable in every engine the app targets, so equal keys +keep insertion order. PostgreSQL guarantees no such thing. **A tiebreaker is +required:** every `ORDER BY` appends `, id` so repeated identical requests +return the same order. Without it, paginated lists can drop or duplicate rows +between pages. + +### 7.4 Insertion order is newest-first + +`create()` does `table().unshift(record)` — new records go to the **front**. With +the default `-created_date` sort this is already the outcome, so it is only +observable when two records share a timestamp or when `sort` is empty. The `, id` +tiebreaker does not reproduce it exactly; the divergence is limited to +same-millisecond inserts and nothing depends on it. + +**Related:** `create()` spreads `...data` *after* the generated `id` and +`created_date`, so a caller-supplied `id` or `created_date` **wins** today. No +caller does this, which is why §3.1 ignores those fields rather than honouring +them — but a seeder that needs to preserve ids must write them directly, not +through `POST`. + +--- + +## 8. Pagination semantics + +**The frontend does not paginate over the network.** This is the single most +important thing not to redesign. + +Every collection is fetched once, whole, up to a hard cap, and every page then +filters, sorts, searches and paginates **in the browser** over that array. +`components/ds/Pagination.jsx` is a pure client control; `DataTable` slices +`sortedRows` locally. `DataTable` does accept a `serverPaginated` prop — **no +page passes it.** + +| Rule | Behaviour | +| --- | --- | +| `?limit=` | Max rows returned. Defaults to the per-endpoint value in §8.1. | +| `?offset=` | Rows to skip. Defaults to `0`. **No current caller sends it.** | +| Cap | `limit` is clamped to `1000`. Nothing requests more than 500. | +| Empty result | `{"data": [], "meta": {"total": 0, …}}` and **HTTP 200**. Never 404. `store.js` returns `[]`, and every consumer defaults with `= []`. | + +### 8.1 Per-endpoint default limits — these are not arbitrary + +Each default is the exact second argument at the call site. Changing one changes +what the UI shows, because the truncation happens before any client-side filter +runs. + +| Endpoint | Default `limit` | Default `sort` | Call site | +| --- | --- | --- | --- | +| `/job-postings` | 100 | `-created_date` | `krowHooks.js:68` | +| `/job-applications` | **200** | **`-ai_score`** | `krowHooks.js:85-86` | +| `/ai-interviews` | 100 | `-created_date` | `krowHooks.js:93` | +| `/shift-records` | **500** | `-created_date` | `krowHooks.js:108` | +| `/staff` | 100 | `-created_date` | `krowHooks.js:115` | +| `/role-categories` | 100 | `-created_date` | `krowHooks.js:132` | +| `/certifications` | 200 | `-created_date` | `krowHooks.js:139` | +| `/user-activity` | **500** | `-created_date` | `krowHooks.js:176` | +| `/user-activity` (filtered) | **20** | `-created_date` | `pages/Profile.jsx:28` | +| `/courses` | 200 | `-created_date` | `krowHooks.js:346` | +| `/assignments` | 500 | `-created_date` | `krowHooks.js:391` | +| `/learning-paths` | 100 | `-created_date` | `krowHooks.js:534` | +| `/badges` | 200 | `-created_date` | `krowHooks.js:538` *(no consumer)* | +| `/worker-profiles` | 500 | **`-krow_score`** | `krowHooks.js:583` | +| `/worker-profiles` (filtered) | **1** | `-created_date` | `krowHooks.js:554` | +| `/evidence` | 200 | `-created_date` | `krowHooks.js:633-634` | + +The shim sends these explicitly rather than relying on server defaults, so the +numbers stay visible at the call site where they already live. + +### 8.2 Latency + +`store.js` injects 140 ms on reads and 220 ms on writes so loading states are +real. Real network latency replaces it. The loading states are already designed +and exercised — but they have only ever seen *uniform* latency, so Phase 2D +should test slow and failing responses, not just successful ones. + +--- + +## 9. Current-user / auth contract + +**Authentication is required.** Every endpoint in this document except +`GET /health`, `POST /api/v1/auth/login` and `POST /api/v1/auth/logout` answers +`401` with `{"error":{"code":"unauthorized","message":"authentication required"}}` +unless the request carries a valid session cookie. + +`base44.auth` surface in use: `me()` ×11, `logout()` ×5, `updateMe()` ×3, +`preferences()` ×2, `updatePreferences()` ×1, `redirectToLogin()` ×1, plus +`login()`. + +### `POST /api/v1/auth/login` + +Public. Body: `{"email": "…", "password": "…", "remember_me": false}`. + +`200` returns the user, in the same shape as `GET /me`, and sets a session +cookie: `krow_session=; Path=/; HttpOnly; SameSite=Lax`, plus +`Secure` when `APP_ENV != development`, with `Max-Age` matching the session +lifetime — 12 hours normally, 30 days with `remember_me`. + +**The token is never in the response body.** It exists in the `Set-Cookie` +header and in the browser's cookie store; PostgreSQL holds only its SHA-256. + +`422` when `email` or `password` is missing. `429` when too many failed attempts +have been made against this email or from this address. Every credential failure +— wrong password, unknown email, no password set, suspended account — is the +same `401` with the same body, so the endpoint cannot be used to discover which +addresses are registered. + +### `POST /api/v1/auth/logout` + +Public and idempotent. Revokes the session behind the cookie if there is one, +expires the cookie, and answers `200` with `{"data":{"status":"signed_out"}}` — +including when the cookie is absent, stale or was never valid. + +### `GET /api/v1/me` + +Returns the user behind the session cookie. Response `data`: + +```json +{ + "id": "…uuid…", + "legacy_id": "user_demo", + "full_name": "Alex Rivera", + "email": "demo@krow.app", + "role": "admin", + "account_type": "employer", + "created_date": "2026-06-01T00:00:00.000Z", + "preferences": { "owliverDefault": true, "compactDensity": false, "emailDigest": true } +} +``` + +`preferences` is **embedded in the user object**, because `krowHooks.js:42` +reads `user?.preferences` directly. + +### `PATCH /api/v1/me` + +Shallow merge, returns the full updated user. Used by `layouts/Layout.jsx:73` +(`account_type` role switch, unmounted) and `pages/admin/Profile.jsx`. + +### `GET` / `PATCH /api/v1/me/preferences` + +**The one place field naming diverges from the database.** The frontend uses +camelCase keys; Phase 1 stores three of them as snake_case columns plus an +`extra` jsonb blob: + +| API key (camelCase) | Column | +| --- | --- | +| `owliverDefault` | `user_preferences.owliver_default` | +| `compactDensity` | `user_preferences.compact_density` | +| `emailDigest` | `user_preferences.email_digest` | +| everything else | merged into `user_preferences.extra` (jsonb) | + +"Everything else" is not a hypothetical: `customSkills` and `customAgents` — every +account-authored skill and agent definition — live in this blob today, written +by `WorkspaceSkills.jsx`, `SkillEditor.jsx`, `OwliverSkillEditor.jsx` and +`useAgents.js`. They are opaque to the API in v1 and are promoted to real tables +in a later phase. + +`PATCH` **shallow-merges** the supplied keys into the existing preferences and +returns the merged object. + +### The `{ persisted }` return shape + +`auth.updatePreferences()` returns `{ user, persisted, error }`, not a bare user. +This exists because a swallowed `QuotaExceededError` silently lost +account-authored skills — see the comment at `base44Client.js:persistUser`. Over +HTTP a failed write is already a non-2xx, so the shim synthesises +`{ user: data, persisted: true, error: null }` on success and lets the throw +path handle failure. `lib/skills/saveFeedback.js` is the sole consumer and needs +no change. + +### `logout()` / `redirectToLogin()` + +Client-side only in v1 — clear local session state and navigate. **No endpoint.** +Nothing is called over the network. + +--- + +## 9A. Authorization contract + +Authentication answers *who is calling*; this section answers *what they may +do*. Both are implemented. Every rule below is enforced in the API and covered +by a test — nothing here is aspirational. + +### 9A.1 The authority + +`users.role`, and only `users.role`. Three values, fixed by the +`users_role_check` constraint in migration 000001: + +| Role | Who | +|---|---| +| `admin` | runs the platform for the organization | +| `employer` | runs the organization's hiring and workforce | +| `talent` | a worker, acting for themselves | + +**`account_type` is not an authorization field.** It is a display attribute the +user may change on themselves through `PATCH /me`, and nothing in the API reads +it to make a decision. Neither is anything in a request body, a header, or the +browser: the role is read from the `users` row named by the session. + +An unrecognised role authorizes nothing. + +### 9A.2 401, 403 and 404 + +Three refusals, and the difference between them is load-bearing: + +| Status | Code | Meaning | +|---|---|---| +| `401` | `unauthorized` | No valid session. The caller is nobody. | +| `403` | `forbidden` | The caller is known and their **role** does not permit the operation. | +| `404` | `not_found` | The **row** is outside the caller's organization or, for talent, is not theirs. | + +The role check runs in the handler before any query, so it is always `403` and +never reveals whether a row exists. Organization and ownership are SQL +predicates, so a row outside them is simply absent — a caller cannot tell "it +exists and is not yours" from "it does not exist". The `403` message names +neither the caller's role nor the roles that would have worked. + +`DELETE` continues to answer `200` whether or not a row matched (§12.7), which +discloses nothing either way. + +### 9A.3 Permission matrix + +Read `own` as "restricted to their own rows by a SQL predicate" — see §9A.4. + +| Resource | Admin | Employer | Talent | +|---|---|---|---| +| `/me`, `/me/preferences` | R U | R U | R U | +| `job-postings` | R C U | R C U | **R** *(active only)* | +| `job-applications` | R C U D | R C U D | **R C** *(own)* | +| `ai-interviews` | R C | R C | **R C** *(own)* | +| `staff` | R C U | R C U | — | +| `worker-profiles` | R C U | R C U | **R C U** *(own)* | +| `assignments` | R C | R C | **R** *(own)* | +| `shift-records` | R | R | **R** *(own)* | +| `courses` | R C U | R | R | +| `learning-paths` | R | R | R | +| `role-categories` | R C | R C | R | +| `certifications` | R C D | R C | R | +| `user-activity` | R C | R C | **R** *(own)* C | +| `evidence` | R C U | R C U | **R C** *(own)* | +| `badges` | — | — | — | + +Two admin-only operations, and both have a reason beyond seniority: + +- **`POST`/`PATCH /courses`** — a course with a NULL `org_id` is the shared + platform library, visible to every tenant, so a write here can reach beyond + the writer's own organization. +- **`DELETE /certifications/{id}`** — deleting one changes what every existing + posting that required it means. + +`badges` has no endpoints at all (`Ops: 0`); its empty policy is written down so +the resource is deliberately closed rather than merely forgotten. + +### 9A.4 Ownership + +For a talent caller, reads and writes carry a second predicate beside the +organization scope, in the same `WHERE` clause: + +| Resource | Predicate | +|---|---| +| `worker-profiles` | `user_id = ` | +| `job-applications` | `email = ` | +| `assignments` | `worker_email = ` | +| `shift-records` | `worker_email = ` | +| `evidence` | `worker_email = ` | +| `user-activity` | `user_email = ` | +| `ai-interviews` | `application_id IN (SELECT id FROM job_applications WHERE org_id = … AND email = )` | +| `job-postings` | `status = 'active'` — visibility rather than ownership | + +It is a predicate rather than a filter over fetched rows, which matters for more +than tidiness: `count(*)` runs over the same clause, so the `meta.total` a talent +caller sees is their own count and not the organization's. + +Admin and employer are not row-scoped. Two employers in one organization see and +edit the same rows; that is what an operator console is. + +### 9A.5 Server-owned identity + +Six columns are filled in from the session and ignored if present in a request +body. They are the columns any ownership rule rests on, so a caller who could +set them could defeat the rule with the same request it constrains. + +| Column | Filled with | When | +|---|---|---| +| `job_postings.created_by` | session user id | always | +| `user_activity.user_id` | session user id | always | +| `user_activity.user_email` | session email | always | +| `user_activity.user_name` | session full name | always | +| `user_activity.account_type` | session account type | always | +| `worker_profiles.user_id` | session user id | **talent callers only** | + +Two more are overridden for talent callers specifically, and left writable for +operators: `job_applications.email` and `evidence.worker_email`. + +The distinction is between *who acted* and *who the row is about*. `created_by` +and the `user_activity` columns record the actor, so they are the session user +whoever that is. The others record the subject — and when an admin creates a +candidate's worker profile or files an application on their behalf, the subject +is the candidate, not the operator. Deriving those unconditionally would file +every candidate's record under whoever typed it in. + +`org_id` remains server-owned on every resource, as it has been since Phase 2C. + +### 9A.6 Not implemented + +No permissions table, no policy engine, no per-record ACL, no role hierarchy and +no delegation. Authorization is a role, an organization and an ownership +predicate. Employer and Talent have no frontend surface yet; the backend +enforces their rules regardless, because an API that is only as safe as its +client is not safe. + +--- + +## 10. Frontend → API → database mapping + +### 10.1 Entity operation matrix + +Derived by enumerating every `entities..(` call site in `src/`. There +are exactly six files that touch the seam. + +| Entity | list | filter | get | create | update | delete | Frontend consumers | +| --- | :-: | :-: | :-: | :-: | :-: | :-: | --- | +| JobPosting | ✅ | — | ✅ | ✅ | ✅ | — | `useJobPostings` (18), `useJobPosting`, `useCreateJobPosting`, `useUpdateJobPosting`, `useGenerateJobDescription` | +| JobApplication | ✅ | ✅ | — | ✅ | ✅ | ✅ | `useApplications` (15), `useCreateApplication`, `useUpdateApplication`, `useScreenCandidate`, `useScreenAllCandidates`, `useHireCandidate`, `useAssignWorkers`, `useMarkInterviewReady`, `CandidateCard.jsx`, `admin/Candidates.jsx`, `PositionDetail.jsx` | +| AIInterview | ✅ | — | — | ✅ | — | — | `useInterviews` (8), `useCreateInterview` | +| Staff | ✅ | — | — | ✅ | ✅ | — | `useStaff` (12), `useHireCandidate`, `useUpdateStaff` | +| WorkerProfile | ✅ | ✅ | — | ✅ | ✅ | — | `useWorkerProfiles` (13), `useWorkerProfile` (9), `useSubmitChallenge`, `useUpdateWorkerProfile` ⚠️ | +| Course | ✅ | — | ✅ | ✅ | ✅ | — | `useCourses` (10), `useCourse`, `useCreateCourse`, `useUpdateCourse` | +| LearningPath | ✅ | — | — | — | — | — | `useLearningPaths` → `CourseDetail.jsx` | +| RoleCategory | ✅ | — | — | ✅ | — | — | `useRoleCategories` → `CreatePosition.jsx`, `KrowAssistant.jsx` | +| Certification | ✅ | — | — | ✅ | — | ✅ | `CertificationManager.jsx` ⚠️, `KrowIdentity.jsx` ⚠️ | +| UserActivity | ✅ | ✅ | — | ✅ | — | — | `useUserActivity` (6), `logActivity` (`userTracking.js`), `Profile.jsx` ⚠️ | +| Evidence | ✅❌ | ✅❌ | — | ✅ | ✅ | — | `useSubmitChallenge`, `useVerifyEvidence` ← `ChallengeRunner` ← `CourseDetail.jsx`. `useEvidenceList` has **no consumers**. | +| Assignment | ✅ | — | — | ✅ | — | — | `useAssignments` (6), `useAssignWorkers` | +| ShiftRecord | ✅ | — | — | — | — | — | `useShiftRecords` → `KrowAssistant.jsx`, `SkillSurface.jsx` | +| Badge | ✅❌ | — | — | — | — | — | `useBadges` — **no consumers** | +| User | — | — | — | — | — | — | Never via the entity API. Only `auth.me` / `updateMe` / `preferences`. | + +⚠️ = only reachable from an unmounted route (D6). ❌ = call site exists but the +hook has no consumer. `(n)` = number of importing files. + +**Note on `Evidence.list`:** unreachable even if `useEvidenceList` gained a +consumer — the hook is `enabled: !!workerEmail`, so the `.list()` branch cannot +execute. + +### 10.2 Resource → table mapping + +Every mapping is 1:1 against Phase 1. **No schema change is required.** + +| API resource | Frontend entity | Table | PK | Foreign keys | +| --- | --- | --- | --- | --- | +| `/job-postings` | JobPosting | `job_postings` | `id` uuid | `org_id`, `created_by`→`users` | +| `/job-applications` | JobApplication | `job_applications` | `id` uuid | `org_id`, `job_posting_id`, `worker_profile_id` | +| `/ai-interviews` | AIInterview | `ai_interviews` | `id` uuid | `org_id`, `application_id`, `job_posting_id` | +| `/staff` | Staff | `staff` | `id` uuid | `org_id`, `application_id`, `job_posting_id`, `worker_profile_id` | +| `/worker-profiles` | WorkerProfile | `worker_profiles` | `id` uuid | `org_id`, `user_id` | +| `/courses` | Course | `courses` | `id` uuid | `org_id` *(nullable — platform library)* | +| `/learning-paths` | LearningPath | `learning_paths` | `id` uuid | `org_id` *(nullable)* | +| `/role-categories` | RoleCategory | `role_categories` | `id` uuid | `org_id` | +| `/certifications` | Certification | `certifications` | `id` uuid | `org_id` | +| `/user-activity` | UserActivity | `user_activity` | `id` bigint identity | `org_id`, `user_id`. `position_id`/`application_id`/`candidate_id`/`interview_id` are **unconstrained uuids by design** — an activity row must survive deletion of what it describes. | +| `/evidence` | Evidence | `evidence` | `id` uuid | `org_id`, `course_id`, `worker_profile_id` | +| `/assignments` | Assignment | `assignments` | `id` uuid | `org_id`, `job_posting_id`, `application_id`, `worker_profile_id` | +| `/shift-records` | ShiftRecord | `shift_records` | `id` uuid | `org_id`, `staff_id`, `assignment_id`, `job_posting_id` | +| *(none)* | Badge | `badges` | `id` uuid | `org_id` | +| `/me` | User | `users` + `user_preferences` | `id` uuid | `org_id` | + +### 10.3 Organization scope + +Every table carries `org_id`. **v1 resolves it to the single seeded +organization** — there is no tenant in the request, because there is no auth. + +The repository layer must nonetheless take `org_id` as a parameter from day one +rather than defaulting it inside a query. When auth lands, the only change is +where the value comes from. A query that hardcodes the org is a query that has +to be rewritten. + +`courses.org_id` and `learning_paths.org_id` are nullable — `NULL` means the +shared platform library. Reads must match `org_id = $1 OR org_id IS NULL`. + +### 10.4 Enum mapping + +All fifteen Phase 1 enums pass through as their literal string values. No +translation layer, no integer codes. The frontend compares +`status === 'ai_screened'` against the raw string and `StatusBadge.jsx`'s +`STATUS_MAP` is keyed on it. + +| Column | Values | +| --- | --- | +| `job_postings.status` | `draft` `active` `paused` `closed` | +| `job_postings.priority` | `urgent` `high` `normal` | +| `job_postings.english_required`, `job_applications.english_level` | `basic` `conversational` `fluent` `native` | +| `job_applications.status` | `applied` `ai_screened` `shortlisted` `interview` `hired` `rejected` `assigned` | +| `ai_interviews.verdict` | `hire` `maybe` `no` | +| `staff.status` | `onboarding` `active` `inactive` | +| `staff.profile_tier` | `Beginner` `Cross-Trained` `Skilled` *(capitalised — matches `STATUS_MAP`)* | +| `assignments.status` | `active` `completed` `cancelled` | +| `shift_records.status` | `present` `late` `absent` `no_show` | +| `courses.status` | `active` `inactive` | +| `courses.target_level` / `required_level` | `beginner` `intermediate` `advanced` `expert` | +| `evidence.type` | `roleplay` `video` `photo_identify` | +| `evidence.ai_verdict` | `verified` `needs_work` `failed` | +| `badges.level` | `bronze` `silver` `gold` `platinum` | +| `badges.verification_status` | `pending` `verified` `expired` | + +An invalid enum value is `422 validation_failed`, not a silent coercion. + +--- + +## 11. Deferred decisions + +### D2 — Is `company` a real entity? **DEFERRED. Does not affect this API.** + +`company` is free text on `job_postings` (`positionModel.js:74` defaults it to +`''`, `:125` trims it). It is displayed (`PositionDetail.jsx:298`), used in +Owliver's summary lines, and read through a fallback chain in +`hiringRecords.js:39`. It is **never** grouped by id, joined, or given its own +route or hook. No `Company` entity exists. + +`hiringRecords.js:39` reads `s.company` on a Staff record — but no Staff record +carries one; it is a defensive `||` fallback that always resolves to +`posting?.company`. **This is not a schema contradiction.** + +**Decision:** keep `company text NOT NULL DEFAULT ''` on `job_postings`. No +`clients` table, no endpoint, no change. Promoting it later adds a resource and +a nullable FK; it does not alter any endpoint defined here. + +### D3 — Are `assigned` / `rejected` pipeline stages? **DEFERRED. Does not affect this API.** + +`STAGE_ORDER = ['applied','ai_screened','shortlisted','interview','hired']`, +duplicated verbatim in `hiringRecords.js:99`, `admin/positionInsights.js:9` and +`skills/dataResolver.js:112`. Funnels count `STAGE_ORDER.indexOf(a.status) >= from`. +`indexOf` returns `-1` for `assigned` and `rejected`, and `-1 >= 0` is false for +every stage — so **both statuses are excluded from every funnel count**, including +`applied`. Meanwhile `assigned` is written by `useAssignWorkers` +(`krowHooks.js:462` on create, `:466` on update) and `rejected` by `CandidateCard.jsx:118`. + +**Decision:** the enum already carries all seven values. The API stores and +returns `status` verbatim and **never filters, reinterprets or normalises it**. +The funnel exclusion is a live frontend bug in `lib/`, which Phase 2 does not +touch. It becomes a backend decision only when aggregation moves server-side. +Flagged, not fixed — fixing it here would change what the UI displays, which is +out of scope. + +### D6 — Do the unmounted pages come back? **DEFERRED as a product question; three endpoints included regardless.** + +Fourteen page files plus `layouts/Layout.jsx` are imported by `App.jsx` and +mounted on no route: `Overview`, `Positions`, `Candidates`, `HiredHistory`, +`TalentPool`, `Apply`, `UserTracking`, `Analytics`, `Profile`, `WorkerProfile`, +`KrowIdentity`, `Owliver`, `EmployeeDashboard`, `DesignSystem`. + +They are the *sole* consumers of three operations: + +| Operation | Only reachable via | +| --- | --- | +| `Certification.list/create/delete` | `CertificationManager.jsx` ← `pages/Positions.jsx`; `pages/KrowIdentity.jsx` | +| `UserActivity.filter({user_email})` | `pages/Profile.jsx:28` | +| `WorkerProfile.update` via `useUpdateWorkerProfile` | `pages/KrowIdentity.jsx`, `pages/Owliver.jsx` | + +The third needs no decision — `PATCH /worker-profiles/{id}` is already live via +`useSubmitChallenge` (`krowHooks.js:683`), which is reachable through +`ChallengeRunner` ← `CourseDetail.jsx` at `/admin/university/:id`. + +**Decision:** include all three endpoints, marked *unreachable-today*. The +calling code exists in the repository and compiles; the endpoints are derived +from real call sites, not invented. Cost of including: three handlers over +tables that already exist. Cost of excluding: the shim needs conditional methods, +and reinstating them later is a contract change. **If D6 resolves to "delete the +pages", drop these three and the `certifications` table with them.** + +### U1 — Where do shift records come from? **DEFERRED. Does not affect this API.** + +`ShiftRecord` has exactly one seam operation anywhere in the frontend: +`.list('-created_date', 500)` at `krowHooks.js:108`. **No create, no update, no +delete.** Records exist only because `attendanceSeed.js` generates ~120 at boot. +Consumers are `KrowAssistant.jsx` and `SkillSurface.jsx` — Owliver's attendance +and overtime analysis. + +**Decision:** `GET /api/v1/shift-records` only. **No `POST` in this contract** — +adding one would require inventing a write contract with no call site to derive +it from. The read side is fully determined and unblocked. + +**Consequence for Phase 2C:** attendance and overtime are among the richest +features in the product and will be **empty in any real deployment** until a +rostering source exists (an import, an integration, or a scheduling UI — none of +which exist). The seeder must therefore reproduce `attendanceSeed.js`'s +deterministic generation, or Owliver's attendance skills have nothing to read. + +### Still open, not required by this contract + +**D4** — generic entity gateway vs. per-resource REST. This contract specifies +per-resource REST because that is what was asked for and it makes each endpoint's +operations explicit. A generic gateway +(`POST /entities/{Name}/{op}`) would be a thinner shim but would hide the fact +that, say, `JobPosting` has no delete. **D5** (where the 32 shipped definitions +live), **D7–D10** (RAG, embeddings, model routing, conversation persistence) — +none touch v1. + +--- + +## 12. Known limitations + +### 12.1 Multi-record writes are not transactional + +Four flows write several records in a client-side loop, with no transaction and +no rollback. A failure halfway leaves the database inconsistent — this is true +today and **this contract does not fix it.** + +| Flow | Writes | Site | +| --- | --- | --- | +| `useHireCandidate` | `PATCH` application → `POST` staff | `krowHooks.js:302-303` | +| `useAssignWorkers` | per worker: `POST` assignment → `POST`/`PATCH` application → `POST` activity | `krowHooks.js:421`, `:449`, `:466` | +| `useScreenAllCandidates` | one `PATCH` per candidate, sequentially | `krowHooks.js:254` | +| `useSubmitChallenge` | `POST` evidence → `PATCH` worker profile | `krowHooks.js:649-683` | + +Phase 3 should collapse each into one endpoint and one transaction +(`POST /job-applications/{id}/hire`, `POST /job-postings/{id}/assignments`). That +**does** require touching `krowHooks.js`, which is why it is not in v1 — v1's +whole point is that the transport swap changes nothing above the seam. + +Real latency makes this worse, not better: `useAssignWorkers` currently issues +3n sequential round-trips for n workers. At 220 ms of simulated latency that was +already slow; over a network it is slower, and a mid-loop failure is more likely. + +### 12.2 Collection caps hide data silently + +`limit` truncates without telling anyone. `worker-profiles` caps at 500 sorted by +`-krow_score`, so profile 501 is invisible to Talent Pool, to matching, and to +Owliver — with no indication. `meta.truncated` exists to make this fixable; +nothing consumes it yet. + +### 12.3 All aggregation is client-side + +Funnels, KPI tiles, charts, attendance rollups and overtime analysis all compute +in the browser over the capped arrays. Nothing is server-aggregated, and this +contract adds no aggregation endpoint. `shift_records` at 500 rows is the first +thing that breaks as data grows. + +### 12.4 Search is substring matching in the browser + +`admin/Candidates.jsx:91` concatenates `applicant_name`, `email`, `job_title` and +`skills` into one string and calls `.includes()` on the lowercased query. No +endpoint implements search, and none should in v1 — moving it server-side would +change which rows match. + +### 12.5 Case sensitivity diverges, deliberately + +`store.js` compares emails with `===`; `citext` compares case-insensitively. See +§6.1 for why this is safe and intended. + +### 12.6 Sort stability is not guaranteed by the database + +See §7.3. Every `ORDER BY` must append `, id`. + +### 12.7 `DELETE` on a missing record returns 200 + +`store.js` filters the array and returns `{ id }` whether or not anything +matched — it never throws. The API reproduces this: `DELETE` is idempotent and +returns `200` with `{"data":{"id":"…"}}` even when the row was already gone. +**This is the one place the API is deliberately less strict than REST +convention.** `CandidateCard.jsx:132` and `admin/Candidates.jsx:57` delete inside +loops without checking, and a 404 would surface as an error toast where none +appears today. + +### 12.8 Integrations are out of scope + +`InvokeLLM` and `UploadFile` remain local (`aiEngine.js`, blob URLs). Nine AI +workflows run deterministically in the browser. No endpoint here replaces them; +that is the Owliver phase. + +--- + +## 13. Phase 2C addendum — reconciliation and implementation notes + +Phase 2C implemented this contract. Nothing in §1–§12 was redesigned; what +follows records the gaps implementation exposed and the four decisions taken +about them. Each was reported before it was acted on. + +### 13.1 Schema gaps found and closed + +Three columns the frontend reads or writes had no column in migration 000001. +All three were found by sweeping runtime write paths, not just seed data — which +is why the earlier seed-versus-schema diff missed two of them. **Migration +000001 was not modified**; the columns were added in `000002`. + +| Column | Evidence | Resolution | +| --- | --- | --- | +| `job_applications.interview_id` | Written `AIInterviewModal.jsx:180`; read `CandidateExpandedDetails.jsx:41,142`; on 5 of 24 seed records | `uuid`, nullable, **no FK** — `app_devon` references `int_devon`, which no AIInterview record exists for, and nulling it would be transforming source data | +| `courses.training_outline` | Written `AddSkillTraining.jsx:103` ← `University.jsx`; read `challengeMeta.js:139`, `insights.js:177`, `admin.js:1547` | `text[] NOT NULL DEFAULT '{}'` — the writer produces a plain list of strings | +| `worker_profiles.score_breakdown` | Written via `recalcProfilePatch()` at `krowHooks.js:682`; **no reader consumes it from a profile** | `jsonb NOT NULL DEFAULT '{}'` — the column exists so the write lands rather than being discarded | + +### 13.2 A constraint that rejected real data + +`job_applications_screened_consistent` from migration 000001 — +`status = 'applied' OR screened_at IS NOT NULL OR ai_score = 0` — rejected **9 of +24** seeded applications: precisely the 9 AI-scored ones behind the "9 scored, +averaging 76" anchor. `screened_at` is never read or written anywhere in the +frontend; the column and the constraint came from the backend blueprint, not +from repository evidence. Dropped in migration `000003`. The column is retained, +nullable and unused. + +All 53 CHECK constraints were then evaluated against all 245 seeded records on a +throwaway database. The other 52 hold. + +### 13.3 Error codes + +§5's table gains one code that only a developer can trigger: + +| Code | HTTP | When | +| --- | --- | --- | +| `method_not_allowed` | 405 | The path exists but not under this method — `DELETE /job-postings/{id}`, `POST /shift-records` | + +`net/http`'s own plain-text 404 and 405 replies are rewritten into the error +envelope, so every response from the API is JSON. + +A resource with no item route at all — `assignments`, which is only listed and +created — answers **404**, not 405: 405 requires the path pattern to exist under +some other method. + +### 13.4 Deliberate divergences from `store.js` + +Three, all forced and all reported rather than silent: + +1. **Email matching is case-insensitive.** `citext` columns compare without + regard to case where `store.js` used `===`. Safe: `useAssignWorkers` already + lowercases both sides (`krowHooks.js:445`) and `UNIQUE (org_id, email)` makes + a case-variant duplicate impossible. §6.1. +2. **`updated_date` is always populated.** Only job applications carry it in the + source; every other entity has none, and `store.js` therefore returns + `undefined`. The column is `NOT NULL`, so the seeder sets it to + `created_date` — the record has not been modified since creation. One visible + effect: `TalentDetailModal.jsx:66` reads `profile.updated_date || new Date()` + and will now show the seeded date rather than today. +3. **`_order` is dropped.** A positional index `seed.js:1326` uses while + building its course list. No column, no reader. + +### 13.5 Unknown fields are rejected + +§3.1 did not say what to do with a body field that maps to no column. The +implementation answers **422** with the offending field named in `details`. + +Silently ignoring them is exactly how `interview_id`, `training_outline` and +`score_breakdown` would have been lost: the frontend would have written them, +the API would have returned 200, and the data would never have arrived. Rejecting +turns that class of gap into a failing request instead of missing data. +Server-owned fields (`id`, `org_id`, `created_date`, `updated_date`, +`legacy_id`) remain ignored rather than rejected, as §3.1 specifies. + +### 13.6 Organization scoping from the session + +Every request runs as the organization on the authenticated user's row, put on +the request context by the authentication middleware (`internal/httpserver`, +`authenticate`) alongside the identity itself (`internal/authctx`). + +This replaced `devOrgMiddleware`, which put a fixed organization on every request +with no credential behind it. Because the organization was always threaded +explicitly through the service and repository boundaries rather than defaulted +inside the SQL, that swap changed one line and nothing below it — which is what +the arrangement existed for. + +Nothing in the request influences it. The organization is read from the user's +row, the user from the session row, and the session from a cookie value the +client cannot forge without already holding it. A `user_id` or `org_id` in a +body or query string is ignored. + +Scoping is enforced on reads and writes: a record belonging to another +organization is absent from collections and answers 404 by id. `courses` and +`learning_paths` match `org_id = $1 OR org_id IS NULL`, the NULL meaning the +shared platform library. + +### 13.7 Seed data — verified counts + +The Phase 2C brief cited "6 job postings, 22 job applications". Verified against +the source, the dataset is: + +| | Source | Note | +| --- | --- | --- | +| Job postings | **8** | 6 `active`, 1 `paused`, 1 `closed` — "6" is the active count | +| Job applications | **24** | 24 distinct emails, 24 distinct (posting, email) pairs | +| Scored applications | 9, averaging **76.0** | matches the documented anchor | +| Hires | 3, averaging **94.3** | matches | +| Shift records | **115** | generated: 2 absent, 1 no-show, 5 late, 107 present | +| Everything else | 40 courses, 9 profiles, 9 role categories, 8 certifications, 15 activity, 4 interviews, 4 badges, 3 evidence, 2 learning paths, 0 assignments | | + +`assignments` is empty in the source by design, and stays empty. diff --git a/go-api/cmd/api/main.go b/go-api/cmd/api/main.go new file mode 100644 index 0000000..de270e2 --- /dev/null +++ b/go-api/cmd/api/main.go @@ -0,0 +1,135 @@ +// Command api is the Krow HTTP API. +// +// Configuration, a verified PostgreSQL pool, /health, the sign-in endpoints, +// and the entity and current-user endpoints described in docs/api-contract.md. +// +// Every request outside the public allowlist carries a session cookie that this +// process resolves to a real user. The development organization that used to be +// injected into every request is gone: identity now comes from the sessions +// table, and a database with no users is a database nobody can sign in to, +// which is the correct behaviour rather than a gap. +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + "github.com/krow/krow-backend/go-api/internal/auth" + "github.com/krow/krow-backend/go-api/internal/config" + "github.com/krow/krow-backend/go-api/internal/db" + "github.com/krow/krow-backend/go-api/internal/httpserver" +) + +func main() { + if err := run(); err != nil { + slog.Error("fatal", "error", err) + os.Exit(1) + } +} + +func run() error { + cfg, err := config.Load() + if err != nil { + return err + } + + log := newLogger(cfg.Log.Level) + log.Info("starting krow-api", "env", cfg.AppEnv, "database", cfg.DB.Redacted()) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + database, err := db.Open(ctx, cfg.DB) + if err != nil { + return err + } + defer database.Close() + log.Info("database connected", "schema", cfg.DB.Schema) + + server, err := httpserver.New(cfg, database, log) + if err != nil { + return err + } + + // The sweeper's context is cancelled by the same signal that stops the + // server, so the ticker goes away with the process rather than outliving + // the pool it queries. + go sweepSessions(ctx, server.Sessions(), log) + + errCh := make(chan error, 1) + go func() { errCh <- server.Start() }() + log.Info("listening", "addr", server.Addr(), "endpoints", server.Endpoints(), + "health", "http://"+server.Addr()+"/health", + "cors_origins", cfg.HTTP.CORSOrigins) + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + log.Info("shutdown signal received, draining") + // context.Background: ctx is already cancelled, and Shutdown needs a + // live deadline of its own to drain in-flight requests. + return server.Shutdown(context.Background()) + } +} + +// sweepInterval is how often dead sessions are collected. +// +// Sweeping is housekeeping, not correctness: Manager.Authenticate already +// refuses an expired session and deletes the row as it finds it, so a session +// is never usable between its expiry and the next sweep. This only collects the +// rows nobody comes back for. Fifteen minutes keeps the table from growing +// without putting a DELETE on any hot path. +const sweepInterval = 15 * time.Minute + +// sweepSessions deletes expired sessions until the context is cancelled. +// +// It runs once immediately so a process that has been down for a while does not +// carry a backlog for a further fifteen minutes, then on the ticker. A failed +// sweep is logged and retried at the next tick: the table being briefly larger +// than it should be is not worth stopping the API for. +func sweepSessions(ctx context.Context, sessions *auth.Manager, log *slog.Logger) { + ticker := time.NewTicker(sweepInterval) + defer ticker.Stop() + + sweep := func() { + // A deadline of its own, so a slow or wedged DELETE cannot leave this + // goroutine blocked past shutdown. + sweepCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + n, err := sessions.Sweep(sweepCtx) + switch { + case err != nil && ctx.Err() != nil: + // Shutting down; the cancellation is expected, not a failure. + case err != nil: + log.Warn("session sweep failed", "error", err) + case n > 0: + log.Info("swept expired sessions", "deleted", n) + default: + log.Debug("session sweep found nothing to delete") + } + } + + sweep() + for { + select { + case <-ctx.Done(): + log.Debug("session sweeper stopped") + return + case <-ticker.C: + sweep() + } + } +} + +func newLogger(level string) *slog.Logger { + var lvl slog.Level + if err := lvl.UnmarshalText([]byte(level)); err != nil { + lvl = slog.LevelInfo + } + return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})) +} diff --git a/go-api/cmd/seed/main.go b/go-api/cmd/seed/main.go new file mode 100644 index 0000000..0b531ff --- /dev/null +++ b/go-api/cmd/seed/main.go @@ -0,0 +1,75 @@ +// Command seed loads the frontend's demo dataset into PostgreSQL. +// +// Safe to run repeatedly: every record's key is derived deterministically from +// its source id and written with ON CONFLICT DO UPDATE inside one transaction, +// so re-running restores the seeded values without duplicating a row or +// deleting anything. See internal/seeder. +// +// make seed +package main + +import ( + "context" + "fmt" + "os" + "sort" + "time" + + "github.com/krow/krow-backend/go-api/internal/config" + "github.com/krow/krow-backend/go-api/internal/db" + "github.com/krow/krow-backend/go-api/internal/seeder" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "seed failed:", err) + os.Exit(1) + } +} + +func run() error { + cfg, err := config.Load() + if err != nil { + return err + } + + fixture, err := seeder.Load(cfg.Seed.FixturePath) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + database, err := db.Open(ctx, cfg.DB) + if err != nil { + return err + } + defer database.Close() + + started := time.Now() + result, err := seeder.New(database.Pool, fixture, time.Now()).Run(ctx) + if err != nil { + return err + } + + names := make([]string, 0, len(result.Counts)) + total := 0 + for name, n := range result.Counts { + names = append(names, name) + total += n + } + sort.Strings(names) + + fmt.Printf("seeded into %s (organization %s)\n", cfg.DB.Name, result.OrgID) + for _, name := range names { + fmt.Printf(" %-16s %4d\n", name, result.Counts[name]) + } + fmt.Printf(" %-16s %4d records in %s\n", "TOTAL", total, time.Since(started).Round(time.Millisecond)) + if result.Pruned > 0 { + // Shift records are a rolling window; ones that fell out of it are + // removed. Said out loud, because a seed that deletes should say so. + fmt.Printf(" %-16s %4d stale shift record(s) outside the current window\n", "PRUNED", result.Pruned) + } + return nil +} diff --git a/go-api/cmd/setpassword/main.go b/go-api/cmd/setpassword/main.go new file mode 100644 index 0000000..24c5486 --- /dev/null +++ b/go-api/cmd/setpassword/main.go @@ -0,0 +1,259 @@ +// Command setpassword sets a user's password. +// +// It exists because migration 000001 left users.password_hash nullable and +// NULL, and the seeded demo user still has no password. Nothing in the seed +// fixture, the migrations or this repository contains, generates or defaults a +// password: a password enters the system here, typed by a person, and nowhere +// else. +// +// # prompt for the password, twice, with the input hidden +// cd go-api && go run ./cmd/setpassword -email demo@krow.app +// cd go-api && go run ./cmd/setpassword -id 9a1f...-uuid +// +// # non-interactive, for a provisioning script — the password arrives on +// # stdin, never in argv, so it does not reach `ps` or the shell history +// printf '%s' "$NEW_PASSWORD" | go run ./cmd/setpassword -email demo@krow.app -stdin +// +// There is deliberately no -password flag. A password in argv is visible to +// every process on the machine through `ps`, and lands in the shell history +// besides. stdin is the only non-interactive route. +// +// The password, the confirmation and the resulting hash are never printed, +// never logged and never written anywhere but the users.password_hash column, +// through a bind parameter. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "golang.org/x/term" + + "github.com/krow/krow-backend/go-api/internal/auth" + "github.com/krow/krow-backend/go-api/internal/config" + "github.com/krow/krow-backend/go-api/internal/db" +) + +func main() { + if err := run(); err != nil { + // The error strings in this file name rules and identifiers only. No + // path here can carry the password into this line. + fmt.Fprintf(os.Stderr, "setpassword: %v\n", err) + os.Exit(1) + } +} + +type target struct { + id string + email string + role string + hadHash bool +} + +func run() error { + var ( + email = flag.String("email", "", "the user's email address") + id = flag.String("id", "", "the user's UUID") + fromStdin = flag.Bool("stdin", false, "read the password from stdin instead of prompting") + ) + flag.Usage = func() { + fmt.Fprintf(flag.CommandLine.Output(), + "Usage: setpassword (-email
| -id ) [-stdin]\n\n"+ + "Sets one user's password, hashed with argon2id. The password is never\n"+ + "echoed, printed or logged, and there is no -password flag by design.\n\n") + flag.PrintDefaults() + } + flag.Parse() + + if flag.NArg() > 0 { + // A bare argument is most likely someone typing the password after the + // command. Refuse loudly rather than ignoring it — and say nothing + // about what the argument was. + return errors.New("unexpected positional argument; pass -email or -id, and supply the password when prompted") + } + if (*email == "") == (*id == "") { + return errors.New("pass exactly one of -email or -id") + } + + cfg, err := config.Load() + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + database, err := db.Open(ctx, cfg.DB) + if err != nil { + return err + } + defer database.Close() + + // Resolve and show the target BEFORE asking for a password, so nobody + // types a secret at a prompt that turns out to be pointed at the wrong + // user, or at no user at all. + t, err := resolve(ctx, database, *email, *id) + if err != nil { + return err + } + fmt.Fprintf(os.Stderr, "database: %s\nuser: %s <%s>\nrole: %s\npassword: %s\n\n", + cfg.DB.Name, t.id, t.email, t.role, existingState(t.hadHash)) + + password, err := readPassword(*fromStdin) + if err != nil { + return err + } + // The plaintext lives in this slice and nowhere else. Wipe it as soon as + // the hash exists. Go's garbage collector may still have copied it, so + // this is a reduction in exposure rather than a guarantee — worth doing, + // not worth trusting. + defer wipe(password) + + if err := auth.ValidatePassword(string(password)); err != nil { + return describePolicy(err) + } + + hash, err := auth.HashPassword(string(password)) + if err != nil { + return err + } + + // Parameterized, and keyed by the UUID resolved above rather than by the + // string the operator typed. Neither the hash nor the password is ever + // interpolated into SQL. + const q = `UPDATE users SET password_hash = $2::text, updated_date = now() WHERE id = $1::uuid` + tag, err := database.Pool.Exec(ctx, q, t.id, hash) + if err != nil { + return fmt.Errorf("update password: %w", err) + } + if tag.RowsAffected() != 1 { + return fmt.Errorf("expected to update exactly one user, updated %d", tag.RowsAffected()) + } + + // Confirms the identity and nothing about the secret: no hash, no length, + // no prefix. + fmt.Fprintf(os.Stderr, "password set for %s (%s)\n", t.email, t.id) + return nil +} + +func existingState(had bool) string { + if had { + return "already set (it will be replaced)" + } + return "not set yet" +} + +// resolve finds exactly one user by email or by id. +// +// Email lookup relies on the citext column, so it is case-insensitive, and on +// the global unique index added by migration 000004, so it cannot match two +// users in two organizations. +func resolve(ctx context.Context, database *db.DB, email, id string) (target, error) { + var ( + t target + err error + ) + if email != "" { + const q = `SELECT id::text, email::text, role, password_hash IS NOT NULL + FROM users WHERE email = $1::citext` + err = database.Pool.QueryRow(ctx, q, strings.TrimSpace(email)). + Scan(&t.id, &t.email, &t.role, &t.hadHash) + } else { + const q = `SELECT id::text, email::text, role, password_hash IS NOT NULL + FROM users WHERE id = $1::uuid` + err = database.Pool.QueryRow(ctx, q, strings.TrimSpace(id)). + Scan(&t.id, &t.email, &t.role, &t.hadHash) + } + if errors.Is(err, pgx.ErrNoRows) { + return t, errors.New("no such user") + } + if err != nil { + return t, fmt.Errorf("look up user: %w", err) + } + return t, nil +} + +// readPassword collects the password without echoing it. +// +// Interactively it asks twice and compares, because a mistyped password that +// nobody can see is otherwise only discovered at the next login. With -stdin +// it reads the stream verbatim, minus one trailing newline, so +// `printf '%s' "$P" | setpassword -stdin` and a here-string both work. +func readPassword(fromStdin bool) ([]byte, error) { + if fromStdin { + raw, err := io.ReadAll(os.Stdin) + if err != nil { + return nil, fmt.Errorf("read password from stdin: %w", err) + } + return trimOneNewline(raw), nil + } + + fd := int(os.Stdin.Fd()) + if !term.IsTerminal(fd) { + // Falling back to an echoing read here would print the password to the + // screen and into any transcript. Refuse and name the flag instead. + return nil, errors.New("stdin is not a terminal; re-run with -stdin to read the password from the pipe") + } + + fmt.Fprint(os.Stderr, "New password: ") + first, err := term.ReadPassword(fd) + fmt.Fprintln(os.Stderr) + if err != nil { + return nil, fmt.Errorf("read password: %w", err) + } + + fmt.Fprint(os.Stderr, "Confirm password: ") + second, err := term.ReadPassword(fd) + fmt.Fprintln(os.Stderr) + if err != nil { + wipe(first) + return nil, fmt.Errorf("read confirmation: %w", err) + } + defer wipe(second) + + if string(first) != string(second) { + wipe(first) + return nil, errors.New("the two entries do not match") + } + return first, nil +} + +// describePolicy turns a policy error into advice, still without quoting the +// password or revealing its length. +func describePolicy(err error) error { + switch { + case errors.Is(err, auth.ErrEmptyPassword): + return errors.New("the password is empty") + case errors.Is(err, auth.ErrPasswordTooShort): + return fmt.Errorf("the password is too short; it must be at least %d bytes", auth.MinPasswordLength) + case errors.Is(err, auth.ErrPasswordTooLong): + return fmt.Errorf("the password is too long; the maximum is %d bytes", auth.MaxPasswordLength) + } + return err +} + +// trimOneNewline removes a single trailing "\n" or "\r\n", and only one: a +// password may legitimately end in whitespace, so this strips the line +// terminator a shell adds and nothing more. +func trimOneNewline(b []byte) []byte { + if n := len(b); n > 0 && b[n-1] == '\n' { + b = b[:n-1] + if n := len(b); n > 0 && b[n-1] == '\r' { + b = b[:n-1] + } + } + return b +} + +func wipe(b []byte) { + for i := range b { + b[i] = 0 + } +} diff --git a/go-api/go.mod b/go-api/go.mod new file mode 100644 index 0000000..ff31437 --- /dev/null +++ b/go-api/go.mod @@ -0,0 +1,18 @@ +module github.com/krow/krow-backend/go-api + +go 1.27 + +require ( + github.com/jackc/pgx/v5 v5.10.0 + golang.org/x/crypto v0.42.0 + golang.org/x/term v0.35.0 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.29.0 // indirect +) diff --git a/go-api/go.sum b/go-api/go.sum new file mode 100644 index 0000000..ddcaf60 --- /dev/null +++ b/go-api/go.sum @@ -0,0 +1,32 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go-api/internal/auth/credentials.go b/go-api/internal/auth/credentials.go new file mode 100644 index 0000000..895c6dd --- /dev/null +++ b/go-api/internal/auth/credentials.go @@ -0,0 +1,125 @@ +package auth + +import ( + "context" + "errors" + "sync" +) + +// ErrInvalidCredentials is the single answer to every failed sign-in. +// +// It is returned when the email is unknown, when the password is wrong, when +// the account has no password set, and when the account is suspended. The +// caller cannot tell those apart from the error, which is the point: an error +// that distinguishes them is an account-enumeration oracle, and one that says +// "this account is suspended" confirms the address is real. +// +// The distinction is still made — it goes to the server log, via the reason +// returned alongside this error. +var ErrInvalidCredentials = errors.New("auth: invalid credentials") + +// Reason is why a sign-in failed. For the log, never for the client. +type Reason string + +const ( + ReasonOK Reason = "ok" + ReasonNoSuchUser Reason = "no_such_user" + ReasonNoPassword Reason = "no_password_set" + ReasonNotActive Reason = "user_not_active" + ReasonBadPassword Reason = "wrong_password" +) + +// Credentials verifies a password against a stored user. +type Credentials struct { + users UserStore +} + +// NewCredentials builds the verifier. +func NewCredentials(users UserStore) *Credentials { return &Credentials{users: users} } + +// decoyHash is a real argon2id hash of a value nobody knows. +// +// It exists to close a timing side channel. Without it, an unknown email +// returns as fast as the database can say "no rows" — a millisecond or two — +// while a known email spends the ~100ms that argon2id costs by design. That +// difference is trivially measurable over a network and turns the login +// endpoint into an account-enumeration oracle no matter how carefully the +// error messages are worded. +// +// So every failure that skips the real password check pays for a decoy one +// instead. The comparison always fails; the cost is the entire purpose. +// +// Built once, lazily: it costs a full argon2id derivation, which is worth +// paying on the first failed login rather than on every process start. +var decoyHash = sync.OnceValue(func() string { + token, err := GenerateToken() + if err != nil { + // A hash of a fixed string is still a fine decoy — its only job is to + // take the right amount of time, and it is never compared against + // anything a caller supplies. + token = "decoy-password-that-is-never-correct" + } + hash, err := HashPassword(token) + if err != nil { + return "" + } + return hash +}) + +// burnTime performs a password verification that is guaranteed to fail, so a +// rejected sign-in costs the same as an accepted one. +func burnTime(password string) { + if h := decoyHash(); h != "" { + _, _ = VerifyPassword(h, password) + } +} + +// Verify resolves an email and password to a user. +// +// On success it returns the user and ReasonOK. On any failure it returns +// ErrInvalidCredentials, a zero user, and the reason — which the caller should +// log and must not send to the client. +// +// A non-nil error that is NOT ErrInvalidCredentials is an operational failure +// (the database is down, a stored hash is corrupt) and should become a 500 +// rather than a 401: the caller's credentials were never actually judged. +func (c *Credentials) Verify(ctx context.Context, email, password string) (User, Reason, error) { + user, err := c.users.FindByEmail(ctx, email) + if errors.Is(err, ErrUserNotFound) { + burnTime(password) + return User{}, ReasonNoSuchUser, ErrInvalidCredentials + } + if err != nil { + return User{}, ReasonNoSuchUser, err + } + + if user.PasswordHash == "" { + // The seeded user is in this state until `setpassword` is run against + // it. Refused exactly like a wrong password, at the same cost. + burnTime(password) + return User{}, ReasonNoPassword, ErrInvalidCredentials + } + + ok, err := VerifyPassword(user.PasswordHash, password) + if err != nil { + // The stored hash could not be read. That is this server's problem, + // not the caller's, and must not be reported as a failed login. + return User{}, ReasonBadPassword, err + } + if !ok { + return User{}, ReasonBadPassword, ErrInvalidCredentials + } + + // Status is checked AFTER the password, and reported the same way. + // + // Order matters: checking it first would let anyone learn that an address + // belongs to a suspended account without knowing its password, because the + // refusal would arrive without paying the argon2 cost. Checking it after + // means a suspended account is indistinguishable from a wrong password — + // same answer, same timing. + if !user.IsActive() { + return User{}, ReasonNotActive, ErrInvalidCredentials + } + + return user, ReasonOK, nil +} diff --git a/go-api/internal/auth/password.go b/go-api/internal/auth/password.go new file mode 100644 index 0000000..65ff761 --- /dev/null +++ b/go-api/internal/auth/password.go @@ -0,0 +1,230 @@ +// Package auth is the authentication foundation: password hashing, session +// tokens, and the server-side session lifecycle. +// +// It deliberately knows nothing about HTTP. There is no handler, no cookie and +// no middleware here — those arrive in a later phase and will be written in +// terms of this package, not inside it. What lives here is the part that must +// be correct regardless of transport: how a password becomes a hash, how a +// session token is generated and stored, and when a session stops being valid. +// +// Two rules hold throughout, and every function below is written to keep them: +// +// - A raw session token exists in exactly two places: the response that +// created it, and the client's cookie. The database holds SHA-256 of it. +// - Neither a password nor a token nor a password hash is ever returned in an +// error, formatted into a string, or logged. Nothing in this package logs. +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +// Password policy errors. They describe the rule that was broken and never +// echo the password back. +var ( + ErrEmptyPassword = errors.New("auth: password is empty") + ErrPasswordTooShort = errors.New("auth: password is shorter than the minimum length") + ErrPasswordTooLong = errors.New("auth: password is longer than the maximum length") + + // ErrInvalidHash means the stored value is not a hash this package wrote: + // wrong prefix, wrong field count, or unparseable parameters. + ErrInvalidHash = errors.New("auth: password hash is malformed") + + // ErrIncompatibleVersion means the hash was produced by a future argon2 + // version this binary cannot verify. Distinguished from ErrInvalidHash + // because it is an upgrade problem, not corruption. + ErrIncompatibleVersion = errors.New("auth: password hash uses an unsupported argon2 version") +) + +const ( + // MinPasswordLength is measured in bytes, not runes. A byte floor is the + // honest one: it is what the KDF consumes, and counting runes would let a + // short ASCII password through by way of a generous rune count. + MinPasswordLength = 12 + + // MaxPasswordLength caps the input. Argon2 has no internal length limit — + // unlike bcrypt, it does not silently truncate — so the only reason for a + // ceiling is to stop an unbounded body from being hashed at 64 MiB of + // memory per attempt. 1 KiB is far above any real passphrase. + MaxPasswordLength = 1024 +) + +// PasswordParams are the argon2id cost parameters. +// +// They are stored inside every hash this package writes, so a future increase +// does not invalidate existing hashes: verification reads the parameters out of +// the stored string rather than assuming today's defaults. +type PasswordParams struct { + // Memory is the KiB of memory the KDF fills. This is the parameter that + // makes GPU and ASIC attacks expensive, and the one worth raising first. + Memory uint32 + // Time is the number of passes over that memory. + Time uint32 + // Threads is the parallelism (argon2's `p`). + Threads uint8 + // SaltLength and KeyLength are in bytes. + SaltLength uint32 + KeyLength uint32 +} + +// DefaultPasswordParams follows the OWASP Password Storage Cheat Sheet's +// argon2id recommendation: 64 MiB of memory, 3 iterations, 4 lanes (m=65536, +// t=3, p=4). A 16-byte salt and a 32-byte key are the RFC 9106 defaults. +// +// This costs roughly a tenth of a second per login on developer hardware, +// which is the point: it is a cost an attacker pays per guess. +var DefaultPasswordParams = PasswordParams{ + Memory: 64 * 1024, + Time: 3, + Threads: 4, + SaltLength: 16, + KeyLength: 32, +} + +// HashPassword hashes a plaintext password with the default parameters. +// +// The returned string is a complete, self-describing PHC record — algorithm, +// version, parameters, salt and digest — and is what belongs in +// users.password_hash. It is safe to store and unsafe to log. +func HashPassword(plain string) (string, error) { + return HashPasswordWithParams(plain, DefaultPasswordParams) +} + +// HashPasswordWithParams is HashPassword with explicit cost parameters. Tests +// use it to run at a cost that does not dominate the test suite; production +// code should call HashPassword. +func HashPasswordWithParams(plain string, p PasswordParams) (string, error) { + if err := ValidatePassword(plain); err != nil { + return "", err + } + if p.SaltLength == 0 || p.KeyLength == 0 || p.Memory == 0 || p.Time == 0 || p.Threads == 0 { + return "", fmt.Errorf("auth: argon2id parameters must all be non-zero") + } + + salt := make([]byte, p.SaltLength) + if _, err := rand.Read(salt); err != nil { + // crypto/rand failing is not recoverable and must never fall back to a + // weaker source: a predictable salt defeats the whole construction. + return "", fmt.Errorf("auth: read salt: %w", err) + } + + key := argon2.IDKey([]byte(plain), salt, p.Time, p.Memory, p.Threads, p.KeyLength) + + // The PHC string format, as produced by the reference implementation: + // $argon2id$v=19$m=65536,t=3,p=4$$ + // Standard base64 without padding, which is what the format specifies. + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, p.Memory, p.Time, p.Threads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(key), + ), nil +} + +// VerifyPassword reports whether plain is the password behind encoded. +// +// A false return with a nil error is the ordinary "wrong password" answer. A +// non-nil error means the *stored hash* could not be read, which is an +// operational problem rather than a failed login, and callers should tell the +// two apart: the first is a 401, the second is a 500. +// +// The digest comparison is constant-time. The length and parameter checks +// before it are not, and do not need to be: they depend only on the stored +// hash, never on the supplied password. +func VerifyPassword(encoded, plain string) (bool, error) { + p, salt, want, err := DecodePasswordHash(encoded) + if err != nil { + return false, err + } + // No policy check on `plain` here. A password that predates a tightened + // minimum length must still be able to log in; the policy applies when a + // password is set, which is where ValidatePassword is called. + if len(plain) > MaxPasswordLength { + return false, nil + } + + got := argon2.IDKey([]byte(plain), salt, p.Time, p.Memory, p.Threads, p.KeyLength) + return subtle.ConstantTimeCompare(got, want) == 1, nil +} + +// ValidatePassword applies the policy for setting a new password. +func ValidatePassword(plain string) error { + switch { + case len(plain) == 0: + return ErrEmptyPassword + case len(plain) < MinPasswordLength: + return ErrPasswordTooShort + case len(plain) > MaxPasswordLength: + return ErrPasswordTooLong + } + return nil +} + +// DecodePasswordHash parses a PHC argon2id record back into its parts. +// +// Exported so that a future re-hash-on-login path can ask whether a stored hash +// was written with weaker parameters than today's default and upgrade it. It +// returns the salt and digest, never the password. +func DecodePasswordHash(encoded string) (p PasswordParams, salt, key []byte, err error) { + // $argon2id$v=19$m=65536,t=3,p=4$$ splits into six fields, the + // first of which is empty because the string starts with the separator. + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[0] != "" { + return p, nil, nil, ErrInvalidHash + } + if parts[1] != "argon2id" { + // bcrypt, argon2i and argon2d all land here. This package writes and + // reads argon2id and nothing else; a different algorithm is a + // migration decision, not something to guess at during a login. + return p, nil, nil, ErrInvalidHash + } + + var version int + if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil { + return p, nil, nil, ErrInvalidHash + } + if version != argon2.Version { + return p, nil, nil, ErrIncompatibleVersion + } + + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.Memory, &p.Time, &p.Threads); err != nil { + return p, nil, nil, ErrInvalidHash + } + if p.Memory == 0 || p.Time == 0 || p.Threads == 0 { + return p, nil, nil, ErrInvalidHash + } + + if salt, err = base64.RawStdEncoding.DecodeString(parts[4]); err != nil { + return p, nil, nil, ErrInvalidHash + } + if key, err = base64.RawStdEncoding.DecodeString(parts[5]); err != nil { + return p, nil, nil, ErrInvalidHash + } + if len(salt) == 0 || len(key) == 0 { + return p, nil, nil, ErrInvalidHash + } + + p.SaltLength = uint32(len(salt)) + p.KeyLength = uint32(len(key)) + return p, salt, key, nil +} + +// NeedsRehash reports whether a stored hash was written with parameters weaker +// than want, so a successful login can transparently upgrade it. +// +// Unused in Phase 3B — there is no login yet — and exported now because the +// judgement belongs beside the format that encodes the parameters. +func NeedsRehash(encoded string, want PasswordParams) bool { + p, _, _, err := DecodePasswordHash(encoded) + if err != nil { + return true + } + return p.Memory < want.Memory || p.Time < want.Time || + p.KeyLength < want.KeyLength || p.SaltLength < want.SaltLength +} diff --git a/go-api/internal/auth/password_test.go b/go-api/internal/auth/password_test.go new file mode 100644 index 0000000..be04373 --- /dev/null +++ b/go-api/internal/auth/password_test.go @@ -0,0 +1,254 @@ +package auth + +import ( + "strings" + "testing" +) + +// testParams runs argon2id at a cost that is still real but does not make the +// suite crawl. Every property under test — salting, verification, the encoded +// format — is independent of the cost, and DefaultPasswordParams is asserted +// separately in TestDefaultPasswordParamsMeetOWASP. +var testParams = PasswordParams{Memory: 8 * 1024, Time: 1, Threads: 2, SaltLength: 16, KeyLength: 32} + +const goodPassword = "correct-horse-battery-staple" + +// 1. Hash generation produces a well-formed, self-describing argon2id record. +func TestHashPasswordProducesArgon2idRecord(t *testing.T) { + hash, err := HashPasswordWithParams(goodPassword, testParams) + if err != nil { + t.Fatalf("HashPasswordWithParams: %v", err) + } + + if !strings.HasPrefix(hash, "$argon2id$") { + t.Fatalf("hash is not argon2id: %q", firstField(hash)) + } + // bcrypt would be "$2a$"/"$2b$"; argon2i and argon2d are different KDFs. + // The decision was argon2id specifically, so assert it rather than merely + // "some hash was produced". + if parts := strings.Split(hash, "$"); len(parts) != 6 { + t.Fatalf("hash has %d fields, want 6 (PHC format)", len(parts)) + } + + got, salt, key, err := DecodePasswordHash(hash) + if err != nil { + t.Fatalf("DecodePasswordHash: %v", err) + } + if got.Memory != testParams.Memory || got.Time != testParams.Time || got.Threads != testParams.Threads { + t.Errorf("decoded params = m=%d,t=%d,p=%d, want m=%d,t=%d,p=%d", + got.Memory, got.Time, got.Threads, testParams.Memory, testParams.Time, testParams.Threads) + } + if len(salt) != int(testParams.SaltLength) { + t.Errorf("salt is %d bytes, want %d", len(salt), testParams.SaltLength) + } + if len(key) != int(testParams.KeyLength) { + t.Errorf("key is %d bytes, want %d", len(key), testParams.KeyLength) + } + // The whole point of the format: the hash must not contain the password. + if strings.Contains(hash, goodPassword) { + t.Error("the encoded hash contains the plaintext password") + } +} + +// 2. The correct password verifies. +func TestVerifyPasswordAcceptsTheCorrectPassword(t *testing.T) { + hash, err := HashPasswordWithParams(goodPassword, testParams) + if err != nil { + t.Fatalf("hash: %v", err) + } + ok, err := VerifyPassword(hash, goodPassword) + if err != nil { + t.Fatalf("VerifyPassword: %v", err) + } + if !ok { + t.Fatal("the correct password did not verify") + } +} + +// 3. An incorrect password is rejected — including the near misses that a +// sloppy comparison would let through. +func TestVerifyPasswordRejectsIncorrectPasswords(t *testing.T) { + hash, err := HashPasswordWithParams(goodPassword, testParams) + if err != nil { + t.Fatalf("hash: %v", err) + } + + wrong := map[string]string{ + "different": "incorrect-horse-battery-staple", + "empty": "", + "prefix": goodPassword[:len(goodPassword)-1], + "suffix appended": goodPassword + "x", + "case flipped": strings.ToUpper(goodPassword), + "whitespace": " " + goodPassword, + } + for name, candidate := range wrong { + t.Run(name, func(t *testing.T) { + ok, err := VerifyPassword(hash, candidate) + if err != nil { + t.Fatalf("VerifyPassword returned an error for a wrong password: %v", err) + } + if ok { + t.Error("a wrong password verified") + } + }) + } +} + +// 4. Different passwords produce different hashes — and so does the SAME +// password hashed twice, which is the stronger property and the one that +// actually depends on the salt being random. +func TestHashPasswordIsSaltedPerCall(t *testing.T) { + a, err := HashPasswordWithParams(goodPassword, testParams) + if err != nil { + t.Fatalf("hash a: %v", err) + } + b, err := HashPasswordWithParams(goodPassword, testParams) + if err != nil { + t.Fatalf("hash b: %v", err) + } + if a == b { + t.Fatal("hashing the same password twice produced identical hashes; the salt is not random") + } + // Both must still verify: a per-call salt is only useful if it travels + // with the hash. + for i, h := range []string{a, b} { + ok, err := VerifyPassword(h, goodPassword) + if err != nil || !ok { + t.Fatalf("hash %d did not verify its own password (ok=%v err=%v)", i, ok, err) + } + } + + c, err := HashPasswordWithParams("a-completely-different-password", testParams) + if err != nil { + t.Fatalf("hash c: %v", err) + } + if c == a { + t.Error("different passwords produced identical hashes") + } + // And a hash must not verify a password it was not made from. + if ok, _ := VerifyPassword(c, goodPassword); ok { + t.Error("a hash verified a password it was not derived from") + } +} + +// 5a. Empty and out-of-policy passwords are refused at hashing time. +func TestHashPasswordRejectsInvalidPasswords(t *testing.T) { + cases := map[string]struct { + password string + want error + }{ + "empty": {"", ErrEmptyPassword}, + "too short": {strings.Repeat("a", MinPasswordLength-1), ErrPasswordTooShort}, + "too long": {strings.Repeat("a", MaxPasswordLength+1), ErrPasswordTooLong}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + hash, err := HashPasswordWithParams(tc.password, testParams) + if err != tc.want { + t.Fatalf("error = %v, want %v", err, tc.want) + } + if hash != "" { + t.Error("a hash was returned alongside the error") + } + // The rejection must not quote the input back. + if tc.password != "" && err != nil && strings.Contains(err.Error(), tc.password) { + t.Error("the error message contains the password") + } + }) + } + + // The boundary itself is allowed: the rule is "at least MinPasswordLength". + if _, err := HashPasswordWithParams(strings.Repeat("a", MinPasswordLength), testParams); err != nil { + t.Errorf("a password of exactly the minimum length was rejected: %v", err) + } +} + +// 5b. A malformed *stored* hash is an error, not a silent "wrong password". +// The distinction matters: one is a 401, the other is a 500. +func TestVerifyPasswordRejectsMalformedHashes(t *testing.T) { + valid, err := HashPasswordWithParams(goodPassword, testParams) + if err != nil { + t.Fatalf("hash: %v", err) + } + fields := strings.Split(valid, "$") + + bad := map[string]string{ + "empty": "", + "not a phc string": "not-a-hash", + "bcrypt": "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", + "argon2i": strings.Replace(valid, "argon2id", "argon2i", 1), + "too few fields": strings.Join(fields[:5], "$"), + "unparseable params": strings.Replace(valid, fields[3], "m=x,t=y,p=z", 1), + "zero memory": strings.Replace(valid, fields[3], "m=0,t=1,p=2", 1), + "bad version": strings.Replace(valid, fields[2], "v=notanumber", 1), + "salt not base64": strings.Replace(valid, fields[4], "!!!!not base64!!!!", 1), + } + for name, encoded := range bad { + t.Run(name, func(t *testing.T) { + ok, err := VerifyPassword(encoded, goodPassword) + if err == nil { + t.Fatal("a malformed hash verified without an error") + } + if ok { + t.Error("a malformed hash reported a successful verification") + } + }) + } + + // A future argon2 version is reported as its own error, because it is an + // upgrade problem rather than corruption. + future := strings.Replace(valid, fields[2], "v=99", 1) + if _, err := VerifyPassword(future, goodPassword); err != ErrIncompatibleVersion { + t.Errorf("error for a future version = %v, want ErrIncompatibleVersion", err) + } +} + +// The defaults are a security decision, so they are asserted rather than +// assumed: OWASP's argon2id recommendation is m=65536 (64 MiB), t=3, p=4. +func TestDefaultPasswordParamsMeetOWASP(t *testing.T) { + p := DefaultPasswordParams + if p.Memory < 64*1024 { + t.Errorf("Memory = %d KiB, want at least 65536", p.Memory) + } + if p.Time < 3 { + t.Errorf("Time = %d, want at least 3", p.Time) + } + if p.Threads < 1 { + t.Errorf("Threads = %d, want at least 1", p.Threads) + } + if p.SaltLength < 16 { + t.Errorf("SaltLength = %d, want at least 16", p.SaltLength) + } + if p.KeyLength < 32 { + t.Errorf("KeyLength = %d, want at least 32", p.KeyLength) + } +} + +func TestNeedsRehash(t *testing.T) { + weak, err := HashPasswordWithParams(goodPassword, testParams) + if err != nil { + t.Fatalf("hash: %v", err) + } + if !NeedsRehash(weak, DefaultPasswordParams) { + t.Error("a hash below the default cost was not flagged for rehashing") + } + strong, err := HashPasswordWithParams(goodPassword, DefaultPasswordParams) + if err != nil { + t.Fatalf("hash: %v", err) + } + if NeedsRehash(strong, DefaultPasswordParams) { + t.Error("a hash at the default cost was flagged for rehashing") + } + if !NeedsRehash("not-a-hash", DefaultPasswordParams) { + t.Error("an unreadable hash should be flagged for rehashing") + } +} + +// firstField is used only to report a failure without dumping a whole hash. +func firstField(hash string) string { + parts := strings.SplitN(hash, "$", 3) + if len(parts) < 2 { + return hash + } + return "$" + parts[1] + "$" +} diff --git a/go-api/internal/auth/schema_test.go b/go-api/internal/auth/schema_test.go new file mode 100644 index 0000000..539b0a0 --- /dev/null +++ b/go-api/internal/auth/schema_test.go @@ -0,0 +1,376 @@ +package auth + +import ( + "context" + "sort" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/krow/krow-backend/go-api/internal/testutil" +) + +// These tests drive migration 000004 itself: what it creates, that it can be +// rolled back, and that rolling it back and re-applying it leaves the schema +// where it started. They also cover the one change 000004 makes to an existing +// table — the global unique index on users.email. +// +// Every one runs in its own throwaway database. Nothing here can reach the +// development database: testutil builds the name from its own prefix. + +const migration4Up = "000004_auth_sessions.up.sql" +const migration4Down = "000004_auth_sessions.down.sql" + +// 14. Email is unique across the whole table, not merely within one +// organization. This is what makes "log in with your email" answerable. +func TestUsersEmailIsGloballyUnique(t *testing.T) { + f := newFixture(t, "schema_email_unique") + + // A second organization. Under 000001's (org_id, email) key alone, the + // insert below would have been perfectly legal. + var otherOrg string + if err := f.pool.QueryRow(f.ctx, + `INSERT INTO organizations (name, slug) VALUES ($1, $2) RETURNING id::text`, + "Second Org", "second-org").Scan(&otherOrg); err != nil { + t.Fatalf("create the second organization: %v", err) + } + + _, err := f.pool.Exec(f.ctx, + `INSERT INTO users (org_id, email, full_name) VALUES ($1::uuid, $2::citext, $3)`, + otherOrg, "session-owner@example.test", "Impostor") + if err == nil { + t.Fatal("the same email was accepted in a second organization; login would be ambiguous") + } + if !strings.Contains(err.Error(), "users_email_global_key") { + t.Errorf("the rejection came from %v, want a users_email_global_key violation", err) + } + + // citext makes the index case-insensitive, which is what a login form + // needs: nobody should be able to register Demo@… beside demo@…. + if _, err := f.pool.Exec(f.ctx, + `INSERT INTO users (org_id, email, full_name) VALUES ($1::uuid, $2::citext, $3)`, + otherOrg, "SESSION-OWNER@EXAMPLE.TEST", "Impostor"); err == nil { + t.Error("a case-variant of an existing email was accepted") + } + + // A genuinely different email in the second organization is still fine — + // the index constrains duplicates, not multi-tenancy. + if _, err := f.pool.Exec(f.ctx, + `INSERT INTO users (org_id, email, full_name) VALUES ($1::uuid, $2::citext, $3)`, + otherOrg, "someone-else@example.test", "Colleague"); err != nil { + t.Errorf("a distinct email in a second organization was rejected: %v", err) + } + + // The index is unique and covers exactly users(email). + var isUnique bool + var definition string + if err := f.pool.QueryRow(f.ctx, + `SELECT i.indisunique, pg_get_indexdef(i.indexrelid) + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + WHERE c.relname = 'users_email_global_key'`).Scan(&isUnique, &definition); err != nil { + t.Fatalf("read users_email_global_key: %v", err) + } + if !isUnique { + t.Error("users_email_global_key is not a unique index") + } + if !strings.Contains(definition, "(email)") { + t.Errorf("users_email_global_key covers %q, want (email)", definition) + } +} + +// 15 and 17. Applying every migration in order produces the sessions table +// this package needs, and leaves everything the earlier migrations built. +func TestMigrationUpBuildsTheSessionsSchema(t *testing.T) { + ctx := context.Background() + pool := testutil.Sandbox(t, "schema_up") + testutil.ApplyAllMigrations(ctx, t, pool) + + // Columns and types. + want := map[string]string{ + "id": "uuid", + "user_id": "uuid", + "token_hash": "text", + "expires_at": "timestamp with time zone", + "absolute_expires_at": "timestamp with time zone", + "created_date": "timestamp with time zone", + "last_seen_at": "timestamp with time zone", + } + rows, err := pool.Query(ctx, + `SELECT column_name, data_type, is_nullable + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'sessions'`) + if err != nil { + t.Fatalf("read the sessions columns: %v", err) + } + got := map[string]string{} + for rows.Next() { + var name, dataType, nullable string + if err := rows.Scan(&name, &dataType, &nullable); err != nil { + t.Fatalf("scan: %v", err) + } + got[name] = dataType + // Every column is required. A nullable expires_at would be a session + // with no deadline at all. + if nullable != "NO" { + t.Errorf("sessions.%s is nullable; every session column is required", name) + } + } + rows.Close() + if err := rows.Err(); err != nil { + t.Fatalf("read the sessions columns: %v", err) + } + if len(got) == 0 { + t.Fatal("the sessions table does not exist after the migrations") + } + for name, wantType := range want { + if gotType, ok := got[name]; !ok { + t.Errorf("sessions.%s is missing", name) + } else if gotType != wantType { + t.Errorf("sessions.%s is %s, want %s", name, gotType, wantType) + } + } + for name := range got { + if _, expected := want[name]; !expected { + t.Errorf("sessions has an unexpected column %q", name) + } + } + + // Constraints: the primary key, the uniqueness that makes a token identify + // one session, and the checks that keep a raw token and an immortal + // session out of the table. + for _, c := range []struct{ name, kind string }{ + {"sessions_pkey", "p"}, + {"sessions_token_hash_key", "u"}, + {"sessions_token_hash_sha256", "c"}, + {"sessions_absolute_after_created", "c"}, + {"sessions_within_absolute", "c"}, + } { + var kind string + if err := pool.QueryRow(ctx, + `SELECT contype::text FROM pg_constraint + WHERE conrelid = 'public.sessions'::regclass AND conname = $1`, c.name).Scan(&kind); err != nil { + t.Errorf("constraint %s is missing: %v", c.name, err) + continue + } + if kind != c.kind { + t.Errorf("constraint %s is of type %q, want %q", c.name, kind, c.kind) + } + } + + // The foreign key, and that it cascades. ON DELETE NO ACTION here would + // mean a deleted user keeps a working session. + var fkTarget, onDelete string + if err := pool.QueryRow(ctx, + `SELECT confrelid::regclass::text, confdeltype::text + FROM pg_constraint + WHERE conrelid = 'public.sessions'::regclass AND contype = 'f'`).Scan(&fkTarget, &onDelete); err != nil { + t.Fatalf("read the sessions foreign key: %v", err) + } + if fkTarget != "users" { + t.Errorf("the foreign key points at %s, want users", fkTarget) + } + if onDelete != "c" { + t.Errorf("the foreign key is ON DELETE %q, want \"c\" (CASCADE)", onDelete) + } + + // Indexes. token_hash is indexed by its UNIQUE constraint — that index is + // the lookup path — plus the two the sweep and per-user revocation need. + indexes := indexNames(ctx, t, pool, "sessions") + for _, name := range []string{"sessions_pkey", "sessions_token_hash_key", "sessions_user_idx", "sessions_expires_idx"} { + if !contains(indexes, name) { + t.Errorf("index %s is missing; sessions has %v", name, indexes) + } + } + + // 17. The earlier migrations are untouched: the tables they built are all + // still here, and so is the org-scoped uniqueness 000001 declared. + // + // Named rather than counted. This was a count of 18 — the 17 tables from + // 000001 plus sessions — which asserted the right thing in the wrong way: + // it broke when 000005 added two unrelated tables, and it would have stayed + // green if 000004 had dropped one table and added another. Listing them is + // both more precise and stable across later migrations. + for _, table := range []string{ + "organizations", "users", "user_preferences", "role_categories", + "certifications", "badges", "courses", "learning_paths", "job_postings", + "worker_profiles", "job_applications", "ai_interviews", "staff", + "assignments", "shift_records", "evidence", "user_activity", + "sessions", + } { + if !tableExists(ctx, t, pool, table) { + t.Errorf("%s is missing after the migrations", table) + } + } + var orgScoped int + if err := pool.QueryRow(ctx, + `SELECT count(*)::int FROM pg_constraint + WHERE conrelid = 'public.users'::regclass AND conname = 'users_org_email_key'`).Scan(&orgScoped); err != nil { + t.Fatalf("read users_org_email_key: %v", err) + } + if orgScoped != 1 { + t.Error("000004 removed users_org_email_key; it should leave the existing table alone") + } +} + +// 16. 000004 rolls back cleanly, and re-applies afterwards. A migration that +// cannot be reversed is a migration nobody can safely deploy. +func TestMigration000004IsReversible(t *testing.T) { + ctx := context.Background() + pool := testutil.Sandbox(t, "schema_down") + testutil.ApplyAllMigrations(ctx, t, pool) + + if !tableExists(ctx, t, pool, "sessions") { + t.Fatal("sessions does not exist before the rollback") + } + + if err := testutil.ApplyMigration(ctx, t, pool, migration4Down); err != nil { + t.Fatalf("apply %s: %v", migration4Down, err) + } + + if tableExists(ctx, t, pool, "sessions") { + t.Error("sessions survived the rollback") + } + if indexExists(ctx, t, pool, "users_email_global_key") { + t.Error("users_email_global_key survived the rollback") + } + + // The rollback must touch nothing else. users is still here, still has the + // constraint that predates 000004, and still has its rows. + if !tableExists(ctx, t, pool, "users") { + t.Fatal("the rollback dropped the users table") + } + var orgScoped int + if err := pool.QueryRow(ctx, + `SELECT count(*)::int FROM pg_constraint + WHERE conrelid = 'public.users'::regclass AND conname = 'users_org_email_key'`).Scan(&orgScoped); err != nil { + t.Fatalf("read users_org_email_key: %v", err) + } + if orgScoped != 1 { + t.Error("the rollback removed users_org_email_key, which it did not create") + } + // With the global index gone, the pre-000004 rule is back in force: the + // same email in two organizations is legal again. + var orgA, orgB string + if err := pool.QueryRow(ctx, `INSERT INTO organizations (name, slug) VALUES ('A','a') RETURNING id::text`).Scan(&orgA); err != nil { + t.Fatalf("create org A: %v", err) + } + if err := pool.QueryRow(ctx, `INSERT INTO organizations (name, slug) VALUES ('B','b') RETURNING id::text`).Scan(&orgB); err != nil { + t.Fatalf("create org B: %v", err) + } + for _, org := range []string{orgA, orgB} { + if _, err := pool.Exec(ctx, + `INSERT INTO users (org_id, email, full_name) VALUES ($1::uuid, $2::citext, '')`, + org, "shared@example.test"); err != nil { + t.Fatalf("insert after the rollback: %v", err) + } + } + + // Re-applying now must fail, because the data violates the uniqueness the + // migration is about to declare — and it must fail without half-applying. + // That is the honest behaviour: the operator has duplicates to resolve. + if err := testutil.ApplyMigration(ctx, t, pool, migration4Up); err == nil { + t.Fatal("000004 applied over duplicate emails; the index would not be unique") + } + if tableExists(ctx, t, pool, "sessions") { + t.Error("the failed migration left the sessions table behind; it is not atomic") + } + + // Resolve the duplicate and it applies cleanly, restoring exactly what the + // rollback removed. + if _, err := pool.Exec(ctx, `DELETE FROM users WHERE org_id = $1::uuid`, orgB); err != nil { + t.Fatalf("remove the duplicate: %v", err) + } + if err := testutil.ApplyMigration(ctx, t, pool, migration4Up); err != nil { + t.Fatalf("re-apply %s: %v", migration4Up, err) + } + if !tableExists(ctx, t, pool, "sessions") { + t.Error("sessions did not come back") + } + if !indexExists(ctx, t, pool, "users_email_global_key") { + t.Error("users_email_global_key did not come back") + } +} + +// Every migration has a matching down file, so any of them can be reversed. +func TestEveryMigrationHasADownFile(t *testing.T) { + ups := testutil.MigrationFiles(t, ".up.sql") + downs := testutil.MigrationFiles(t, ".down.sql") + if len(ups) != len(downs) { + t.Fatalf("%d up migrations and %d down migrations", len(ups), len(downs)) + } + for i, up := range ups { + want := strings.TrimSuffix(up, ".up.sql") + ".down.sql" + if downs[i] != want { + t.Errorf("%s has no matching down migration (found %s)", up, downs[i]) + } + } + // 000004 is still present, with its pair. This used to also assert that it + // was the NEWEST and that there were exactly four migrations — a snapshot + // that this test's own comment predicted would need revisiting, and which + // 000005 duly broke. The count belongs to whichever phase added the newest + // migration (see TestMigrationPairsIncluding000005), so it is asserted + // there and not here. What this phase cares about — that the migration it + // added is intact and reversible — is unchanged and still checked. + if !contains(ups, migration4Up) { + t.Errorf("%s is missing from the migrations directory", migration4Up) + } + if !contains(downs, migration4Down) { + t.Errorf("%s is missing from the migrations directory", migration4Down) + } +} + +/* ── introspection helpers ──────────────────────────────────────────────── */ + +func tableExists(ctx context.Context, t *testing.T, pool *pgxpool.Pool, name string) bool { + t.Helper() + var reg *string + if err := pool.QueryRow(ctx, `SELECT to_regclass('public.' || $1)::text`, name).Scan(®); err != nil { + t.Fatalf("to_regclass(%s): %v", name, err) + } + return reg != nil +} + +func indexExists(ctx context.Context, t *testing.T, pool *pgxpool.Pool, name string) bool { + t.Helper() + var n int + if err := pool.QueryRow(ctx, + `SELECT count(*)::int FROM pg_indexes WHERE schemaname = 'public' AND indexname = $1`, + name).Scan(&n); err != nil { + t.Fatalf("look up index %s: %v", name, err) + } + return n > 0 +} + +func indexNames(ctx context.Context, t *testing.T, pool *pgxpool.Pool, table string) []string { + t.Helper() + rows, err := pool.Query(ctx, + `SELECT indexname FROM pg_indexes WHERE schemaname = 'public' AND tablename = $1`, table) + if err != nil { + t.Fatalf("list the indexes on %s: %v", table, err) + } + defer rows.Close() + var names []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + t.Fatalf("scan: %v", err) + } + names = append(names, name) + } + if err := rows.Err(); err != nil { + t.Fatalf("list the indexes on %s: %v", table, err) + } + sort.Strings(names) + return names +} + +func contains(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} diff --git a/go-api/internal/auth/session.go b/go-api/internal/auth/session.go new file mode 100644 index 0000000..0100406 --- /dev/null +++ b/go-api/internal/auth/session.go @@ -0,0 +1,324 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "time" +) + +// Session lifecycle errors. +var ( + // ErrSessionNotFound means no row matched the token hash. It is returned + // for an unknown token and for a well-formed token that has been revoked; + // a caller must not distinguish the two to the client. + ErrSessionNotFound = errors.New("auth: session not found") + + // ErrSessionExpired means a row matched but is no longer valid, by either + // the sliding or the absolute deadline. + ErrSessionExpired = errors.New("auth: session expired") +) + +// Session is one row of the sessions table. +// +// TokenHash is the SHA-256 of the token, never the token. There is no field +// here that can hold the raw secret, by design: the only place it exists after +// Issue returns is the caller's cookie. +type Session struct { + ID string + UserID string + + // TokenHash is lowercase hex SHA-256. See HashToken. + TokenHash string + + // ExpiresAt is the sliding deadline; it moves forward as the session is + // used, never past AbsoluteExpiresAt. + ExpiresAt time.Time + + // AbsoluteExpiresAt is fixed when the session is created and never moves. + AbsoluteExpiresAt time.Time + + CreatedDate time.Time + LastSeenAt time.Time +} + +// IsExpired reports whether the session is dead at the given instant, by +// either deadline. The absolute one is checked as well as the sliding one +// precisely because the sliding one can be moved. +func (s Session) IsExpired(now time.Time) bool { + return !now.Before(s.ExpiresAt) || !now.Before(s.AbsoluteExpiresAt) +} + +// Policy is how long a session lives. +// +// Two pairs of durations, because "Remember Me" is a different risk than a +// session on a shared machine, and because a sliding window alone can be slid +// forever. +type Policy struct { + // IdleLifetime is how long a normal session survives without being used. + IdleLifetime time.Duration + // AbsoluteLifetime caps a normal session's total life regardless of use. + AbsoluteLifetime time.Duration + + // RememberIdleLifetime and RememberAbsoluteLifetime are the same two + // bounds for a session created with Remember Me. + RememberIdleLifetime time.Duration + RememberAbsoluteLifetime time.Duration +} + +// DefaultPolicy implements the Phase 3B session-lifetime decision. +// +// normal login 12 hours idle, capped at 24 hours of total life. Twelve hours +// covers a working day; the daily cap means an unattended tab +// cannot be slid along indefinitely. +// Remember Me 30 days idle, capped at 90 days. The user asked to stay +// signed in; 90 days is the point at which they re-prove it. +// +// The idle bound is what expires an abandoned session. The absolute bound is +// what guarantees no session lives forever. +var DefaultPolicy = Policy{ + IdleLifetime: 12 * time.Hour, + AbsoluteLifetime: 24 * time.Hour, + RememberIdleLifetime: 30 * 24 * time.Hour, + RememberAbsoluteLifetime: 90 * 24 * time.Hour, +} + +// lifetimes picks the pair that applies to this session. +func (p Policy) lifetimes(remember bool) (idle, absolute time.Duration) { + if remember { + return p.RememberIdleLifetime, p.RememberAbsoluteLifetime + } + return p.IdleLifetime, p.AbsoluteLifetime +} + +func (p Policy) validate() error { + pairs := []struct { + name string + idle, absolute time.Duration + }{ + {"normal", p.IdleLifetime, p.AbsoluteLifetime}, + {"remember-me", p.RememberIdleLifetime, p.RememberAbsoluteLifetime}, + } + for _, pair := range pairs { + if pair.idle <= 0 || pair.absolute <= 0 { + return fmt.Errorf("auth: %s session lifetimes must be positive", pair.name) + } + // An absolute bound below the idle bound would make the idle window + // unreachable, which is a configuration mistake rather than a policy. + if pair.absolute < pair.idle { + return fmt.Errorf("auth: %s absolute lifetime is shorter than its idle lifetime", pair.name) + } + } + return nil +} + +// Store is the persistence the session lifecycle needs. +// +// An interface rather than a concrete type so that the lifecycle rules below +// are testable without a database, and so this package does not depend on pgx. +// The PostgreSQL implementation is PGStore, in store.go. +// +// Every method takes a token *hash*. No implementation ever receives a raw +// token, which is what makes it structurally impossible to store one. +type Store interface { + // Create inserts the session and fills in the id the database assigned, + // which is why it takes a pointer: the id is generated by the default on + // the column, so the caller cannot know it beforehand. + Create(ctx context.Context, s *Session) error + FindByTokenHash(ctx context.Context, tokenHash string) (Session, error) + Touch(ctx context.Context, id string, expiresAt, lastSeenAt time.Time) error + Delete(ctx context.Context, id string) error + DeleteByTokenHash(ctx context.Context, tokenHash string) error + DeleteExpired(ctx context.Context, now time.Time) (int64, error) +} + +// Manager applies the session rules over a Store. +// +// It is the only place that turns a raw token into a hash, and the only place +// that decides whether a session is still alive. +type Manager struct { + store Store + policy Policy + + // now is injectable so the expiry rules can be tested at a chosen instant + // rather than by sleeping. Production always leaves it as time.Now. + now func() time.Time + + // slideThreshold avoids one UPDATE per request. The sliding deadline is + // only pushed forward once the session has used up this fraction of its + // idle window, so a burst of requests writes at most one row. + slideThreshold float64 +} + +// NewManager builds a Manager. An invalid policy is a programming error and is +// reported here rather than at the first login. +func NewManager(store Store, policy Policy) (*Manager, error) { + if store == nil { + return nil, errors.New("auth: session store is required") + } + if err := policy.validate(); err != nil { + return nil, err + } + return &Manager{store: store, policy: policy, now: time.Now, slideThreshold: 0.5}, nil +} + +// WithClock replaces the clock. For tests. +func (m *Manager) WithClock(now func() time.Time) *Manager { + if now != nil { + m.now = now + } + return m +} + +// Policy is the lifetime policy in force. +func (m *Manager) Policy() Policy { return m.policy } + +// Issue creates a session for a user and returns the raw token exactly once. +// +// The token is the return value and is never stored: what reaches the database +// is HashToken(token). The caller's only job is to put the raw token straight +// into an HttpOnly cookie and then forget it — not log it, not echo it in a +// JSON body, not put it in a URL. +func (m *Manager) Issue(ctx context.Context, userID string, remember bool) (string, Session, error) { + if userID == "" { + return "", Session{}, errors.New("auth: user id is required") + } + + token, err := GenerateToken() + if err != nil { + return "", Session{}, err + } + + now := m.now().UTC() + idle, absolute := m.policy.lifetimes(remember) + s := Session{ + UserID: userID, + TokenHash: HashToken(token), + ExpiresAt: now.Add(idle), + AbsoluteExpiresAt: now.Add(absolute), + CreatedDate: now, + LastSeenAt: now, + } + // The idle window can be the longer of the two only through a bad policy, + // which validate() rejects; clamping anyway keeps the database CHECK + // (expires_at <= absolute_expires_at) from being the thing that notices. + if s.ExpiresAt.After(s.AbsoluteExpiresAt) { + s.ExpiresAt = s.AbsoluteExpiresAt + } + + if err := m.store.Create(ctx, &s); err != nil { + return "", Session{}, err + } + return token, s, nil +} + +// Authenticate resolves a raw token to a live session, sliding its expiry. +// +// It returns ErrSessionNotFound for an unknown token and ErrSessionExpired for +// a dead one. Callers must answer the client identically in both cases: which +// of the two it was tells an attacker whether a guessed token ever existed. +// +// An expired row is deleted as it is found, so a session that times out is +// gone rather than waiting for the sweep. +func (m *Manager) Authenticate(ctx context.Context, token string) (Session, error) { + if token == "" { + return Session{}, ErrEmptyToken + } + + hash := HashToken(token) + s, err := m.store.FindByTokenHash(ctx, hash) + if err != nil { + return Session{}, err + } + + now := m.now().UTC() + if s.IsExpired(now) { + // Best effort: failing to delete does not make the session valid. + _ = m.store.Delete(ctx, s.ID) + return Session{}, ErrSessionExpired + } + + if err := m.slide(ctx, &s, now); err != nil { + return Session{}, err + } + return s, nil +} + +// slide moves the sliding deadline forward, bounded by the absolute one. +// +// Only once the session is past slideThreshold of its idle window, so a page +// that fires ten requests does not fire ten UPDATEs. The idle window is +// recovered from the row rather than taken from the policy, so a session keeps +// the lifetime it was issued under even if the policy changes underneath it. +func (m *Manager) slide(ctx context.Context, s *Session, now time.Time) error { + idle := s.ExpiresAt.Sub(s.LastSeenAt) + if idle <= 0 { + return nil + } + if now.Sub(s.LastSeenAt) < time.Duration(float64(idle)*m.slideThreshold) { + return nil + } + + next := now.Add(idle) + if next.After(s.AbsoluteExpiresAt) { + next = s.AbsoluteExpiresAt + } + if err := m.store.Touch(ctx, s.ID, next, now); err != nil { + return err + } + s.ExpiresAt = next + s.LastSeenAt = now + return nil +} + +// Lookup resolves a raw token without sliding the expiry or deleting anything. +// +// A read-only Authenticate, for callers that need to inspect a session without +// treating the call as activity. +func (m *Manager) Lookup(ctx context.Context, token string) (Session, error) { + if token == "" { + return Session{}, ErrEmptyToken + } + s, err := m.store.FindByTokenHash(ctx, HashToken(token)) + if err != nil { + return Session{}, err + } + if s.IsExpired(m.now().UTC()) { + return Session{}, ErrSessionExpired + } + return s, nil +} + +// Revoke deletes the session behind a raw token. This is what logout calls. +// +// Deleting an already-absent session is not an error: logging out twice, or +// logging out with a stale cookie, should succeed rather than fail loudly. +func (m *Manager) Revoke(ctx context.Context, token string) error { + if token == "" { + return ErrEmptyToken + } + err := m.store.DeleteByTokenHash(ctx, HashToken(token)) + if errors.Is(err, ErrSessionNotFound) { + return nil + } + return err +} + +// RevokeID deletes a session by its row id, for callers that already hold one. +func (m *Manager) RevokeID(ctx context.Context, id string) error { + if id == "" { + return errors.New("auth: session id is required") + } + err := m.store.Delete(ctx, id) + if errors.Is(err, ErrSessionNotFound) { + return nil + } + return err +} + +// Sweep deletes every session that is past either deadline, and reports how +// many rows went. Authenticate already removes the expired sessions it meets; +// this collects the ones nobody comes back for. +func (m *Manager) Sweep(ctx context.Context) (int64, error) { + return m.store.DeleteExpired(ctx, m.now().UTC()) +} diff --git a/go-api/internal/auth/session_test.go b/go-api/internal/auth/session_test.go new file mode 100644 index 0000000..d70ec3a --- /dev/null +++ b/go-api/internal/auth/session_test.go @@ -0,0 +1,488 @@ +package auth + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/krow/krow-backend/go-api/internal/testutil" +) + +// The session tests run against a real PostgreSQL database, because what they +// are checking is largely the schema: the unique index that makes lookup work, +// the CHECK that refuses a raw token, and the ON DELETE CASCADE that stops a +// deleted user leaving a live session behind. None of that can be exercised +// against an in-memory fake. +// +// Each test gets its own throwaway database, migrated but not seeded — the +// seed fixture has nothing to say about sessions, and skipping it keeps these +// tests fast. testutil skips rather than fails when PostgreSQL is absent. + +type fixture struct { + pool *pgxpool.Pool + orgID string + userID string + store *PGStore + ctx context.Context +} + +func newFixture(t *testing.T, label string) *fixture { + t.Helper() + ctx := context.Background() + pool := testutil.Sandbox(t, label) + testutil.ApplyAllMigrations(ctx, t, pool) + + f := &fixture{pool: pool, ctx: ctx, store: NewPGStore(pool)} + if err := pool.QueryRow(ctx, + `INSERT INTO organizations (name, slug) VALUES ($1, $2) RETURNING id::text`, + "Auth Test Org", "auth-test-org").Scan(&f.orgID); err != nil { + t.Fatalf("create organization: %v", err) + } + f.userID = f.newUser(t, "session-owner@example.test") + return f +} + +func (f *fixture) newUser(t *testing.T, email string) string { + t.Helper() + var id string + if err := f.pool.QueryRow(f.ctx, + `INSERT INTO users (org_id, email, full_name, role) + VALUES ($1::uuid, $2::citext, $3, 'admin') RETURNING id::text`, + f.orgID, email, "Session Owner").Scan(&id); err != nil { + t.Fatalf("create user %s: %v", email, err) + } + return id +} + +func (f *fixture) sessionCount(t *testing.T) int { + t.Helper() + var n int + if err := f.pool.QueryRow(f.ctx, `SELECT count(*)::int FROM sessions`).Scan(&n); err != nil { + t.Fatalf("count sessions: %v", err) + } + return n +} + +// manager builds a Manager over the fixture's store with a clock the test drives. +func (f *fixture) manager(t *testing.T, p Policy, clock *time.Time) *Manager { + t.Helper() + m, err := NewManager(f.store, p) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + return m.WithClock(func() time.Time { return *clock }) +} + +// shortPolicy keeps the arithmetic in these tests small and legible. The +// production values are asserted separately, in TestDefaultPolicy. +var shortPolicy = Policy{ + IdleLifetime: time.Hour, + AbsoluteLifetime: 3 * time.Hour, + RememberIdleLifetime: 24 * time.Hour, + RememberAbsoluteLifetime: 72 * time.Hour, +} + +// 9. A session is created, and what lands in the database is the hash. +func TestSessionCreation(t *testing.T) { + f := newFixture(t, "session_create") + now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) + m := f.manager(t, shortPolicy, &now) + + token, sess, err := m.Issue(f.ctx, f.userID, false) + if err != nil { + t.Fatalf("Issue: %v", err) + } + + if token == "" { + t.Fatal("Issue returned an empty token") + } + if sess.ID == "" { + t.Error("the session was not given an id") + } + if sess.UserID != f.userID { + t.Errorf("UserID = %q, want %q", sess.UserID, f.userID) + } + if sess.TokenHash != HashToken(token) { + t.Error("the session's token hash is not the hash of the returned token") + } + if sess.TokenHash == token { + t.Fatal("the raw token was stored as the hash") + } + if want := now.Add(shortPolicy.IdleLifetime); !sess.ExpiresAt.Equal(want) { + t.Errorf("ExpiresAt = %v, want %v", sess.ExpiresAt, want) + } + if want := now.Add(shortPolicy.AbsoluteLifetime); !sess.AbsoluteExpiresAt.Equal(want) { + t.Errorf("AbsoluteExpiresAt = %v, want %v", sess.AbsoluteExpiresAt, want) + } + + // The row itself: exactly one, holding the hash and never the token. + var stored string + if err := f.pool.QueryRow(f.ctx, + `SELECT token_hash FROM sessions WHERE id = $1::uuid`, sess.ID).Scan(&stored); err != nil { + t.Fatalf("read the stored session: %v", err) + } + if stored != HashToken(token) { + t.Error("the stored token_hash is not the hash of the token") + } + + // Nothing anywhere in the table equals the token. This is the property the + // whole design exists for, so it is asserted against the database and not + // against the struct. + var leaked int + if err := f.pool.QueryRow(f.ctx, + `SELECT count(*)::int FROM sessions WHERE token_hash = $1::text`, token).Scan(&leaked); err != nil { + t.Fatalf("scan for a leaked token: %v", err) + } + if leaked != 0 { + t.Fatal("the raw token is present in the database") + } + + // Remember Me gets the longer pair of deadlines. + _, remembered, err := m.Issue(f.ctx, f.userID, true) + if err != nil { + t.Fatalf("Issue with remember: %v", err) + } + if want := now.Add(shortPolicy.RememberIdleLifetime); !remembered.ExpiresAt.Equal(want) { + t.Errorf("Remember Me ExpiresAt = %v, want %v", remembered.ExpiresAt, want) + } + if want := now.Add(shortPolicy.RememberAbsoluteLifetime); !remembered.AbsoluteExpiresAt.Equal(want) { + t.Errorf("Remember Me AbsoluteExpiresAt = %v, want %v", remembered.AbsoluteExpiresAt, want) + } +} + +// 10. A live session is found by the token, and only by the right token. +func TestSessionLookup(t *testing.T) { + f := newFixture(t, "session_lookup") + now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) + m := f.manager(t, shortPolicy, &now) + + token, issued, err := m.Issue(f.ctx, f.userID, false) + if err != nil { + t.Fatalf("Issue: %v", err) + } + + got, err := m.Authenticate(f.ctx, token) + if err != nil { + t.Fatalf("Authenticate: %v", err) + } + if got.ID != issued.ID || got.UserID != f.userID { + t.Errorf("Authenticate returned session %q for user %q, want %q / %q", + got.ID, got.UserID, issued.ID, f.userID) + } + + // Lookup is the read-only form and must agree. + if looked, err := m.Lookup(f.ctx, token); err != nil || looked.ID != issued.ID { + t.Errorf("Lookup = %q, %v; want %q, nil", looked.ID, err, issued.ID) + } + + // A different, valid-looking token must not resolve. This is the case the + // unique index and the hash lookup exist to make hopeless. + other, err := GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + if _, err := m.Authenticate(f.ctx, other); !errors.Is(err, ErrSessionNotFound) { + t.Errorf("an unknown token returned %v, want ErrSessionNotFound", err) + } + + // A malformed token must be refused without a round trip, and an empty one + // must be refused before it is hashed at all. + if _, err := m.Authenticate(f.ctx, "not-a-real-token"); !errors.Is(err, ErrSessionNotFound) { + t.Errorf("a malformed token returned %v, want ErrSessionNotFound", err) + } + if _, err := m.Authenticate(f.ctx, ""); !errors.Is(err, ErrEmptyToken) { + t.Errorf("an empty token returned %v, want ErrEmptyToken", err) + } + + // The store is keyed by hash and refuses a raw token outright, so a caller + // that forgets to hash gets an error rather than a silent miss. + if _, err := f.store.FindByTokenHash(f.ctx, token); !errors.Is(err, ErrSessionNotFound) { + t.Errorf("FindByTokenHash with a raw token returned %v, want ErrSessionNotFound", err) + } +} + +// 11. An expired session is rejected, and is cleaned up as it is found. +func TestExpiredSessionIsRejected(t *testing.T) { + f := newFixture(t, "session_expiry") + now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) + m := f.manager(t, shortPolicy, &now) + + token, sess, err := m.Issue(f.ctx, f.userID, false) + if err != nil { + t.Fatalf("Issue: %v", err) + } + + // One second before the deadline it is still good. + now = sess.ExpiresAt.Add(-time.Second) + if _, err := m.Authenticate(f.ctx, token); err != nil { + t.Fatalf("a session one second from expiry was rejected: %v", err) + } + + // Exactly at the deadline it is not. The boundary is closed, not open: a + // session that expires at 12:00 is dead at 12:00. + fresh, err := m.Lookup(f.ctx, token) + if err != nil { + t.Fatalf("Lookup: %v", err) + } + now = fresh.ExpiresAt + if _, err := m.Authenticate(f.ctx, token); !errors.Is(err, ErrSessionExpired) { + t.Fatalf("an expired session returned %v, want ErrSessionExpired", err) + } + + // Finding it expired removes it, so the row does not linger until a sweep. + if n := f.sessionCount(t); n != 0 { + t.Errorf("%d sessions remain after an expired one was authenticated, want 0", n) + } + // And the second attempt cannot tell the client anything different. + if _, err := m.Authenticate(f.ctx, token); !errors.Is(err, ErrSessionNotFound) { + t.Errorf("re-authenticating a swept session returned %v, want ErrSessionNotFound", err) + } +} + +// The absolute ceiling is what stops a sliding window being slid forever. +func TestSessionCannotOutliveItsAbsoluteDeadline(t *testing.T) { + f := newFixture(t, "session_absolute") + start := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) + now := start + m := f.manager(t, shortPolicy, &now) + + token, sess, err := m.Issue(f.ctx, f.userID, false) + if err != nil { + t.Fatalf("Issue: %v", err) + } + ceiling := sess.AbsoluteExpiresAt + + // Keep using the session steadily, well inside the idle window each time, + // right up to the ceiling. The sliding deadline must never cross it. + for _, at := range []time.Duration{50 * time.Minute, 105 * time.Minute, 160 * time.Minute, 175 * time.Minute} { + now = start.Add(at) + got, err := m.Authenticate(f.ctx, token) + if err != nil { + t.Fatalf("Authenticate at +%v: %v", at, err) + } + if got.ExpiresAt.After(ceiling) { + t.Fatalf("at +%v the sliding deadline %v passed the absolute ceiling %v", + at, got.ExpiresAt, ceiling) + } + if !got.AbsoluteExpiresAt.Equal(ceiling) { + t.Fatalf("at +%v the absolute ceiling moved to %v, want %v", at, got.AbsoluteExpiresAt, ceiling) + } + } + + // At the ceiling the session is over, however recently it was used. + now = ceiling + if _, err := m.Authenticate(f.ctx, token); !errors.Is(err, ErrSessionExpired) { + t.Fatalf("at the absolute ceiling Authenticate returned %v, want ErrSessionExpired", err) + } +} + +// 12. Revoking a session deletes it — and revoking twice is not an error, +// because logging out with a stale cookie should succeed. +func TestSessionDeletion(t *testing.T) { + f := newFixture(t, "session_delete") + now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) + m := f.manager(t, shortPolicy, &now) + + token, sess, err := m.Issue(f.ctx, f.userID, false) + if err != nil { + t.Fatalf("Issue: %v", err) + } + if err := m.Revoke(f.ctx, token); err != nil { + t.Fatalf("Revoke: %v", err) + } + if n := f.sessionCount(t); n != 0 { + t.Errorf("%d sessions remain after revocation, want 0", n) + } + if _, err := m.Authenticate(f.ctx, token); !errors.Is(err, ErrSessionNotFound) { + t.Errorf("a revoked token returned %v, want ErrSessionNotFound", err) + } + if err := m.Revoke(f.ctx, token); err != nil { + t.Errorf("revoking twice returned %v, want nil", err) + } + if err := m.RevokeID(f.ctx, sess.ID); err != nil { + t.Errorf("revoking an absent session by id returned %v, want nil", err) + } + + // The store's own contract is stricter: it reports what it did. + if _, _, err := m.Issue(f.ctx, f.userID, false); err != nil { + t.Fatalf("Issue: %v", err) + } + var id string + if err := f.pool.QueryRow(f.ctx, `SELECT id::text FROM sessions LIMIT 1`).Scan(&id); err != nil { + t.Fatalf("read the session id: %v", err) + } + if err := f.store.Delete(f.ctx, id); err != nil { + t.Fatalf("store.Delete: %v", err) + } + if err := f.store.Delete(f.ctx, id); !errors.Is(err, ErrSessionNotFound) { + t.Errorf("deleting an absent session returned %v, want ErrSessionNotFound", err) + } +} + +// 13. Deleting a user deletes their sessions. Without this, a removed account +// keeps working until its cookie happens to expire. +func TestUserDeletionCascadesToSessions(t *testing.T) { + f := newFixture(t, "session_cascade") + now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) + m := f.manager(t, shortPolicy, &now) + + // A second user, so the test can prove the cascade removes one user's + // sessions and leaves the other's alone. + otherID := f.newUser(t, "other-user@example.test") + + doomedToken, _, err := m.Issue(f.ctx, f.userID, false) + if err != nil { + t.Fatalf("Issue for the doomed user: %v", err) + } + if _, _, err := m.Issue(f.ctx, f.userID, true); err != nil { + t.Fatalf("second Issue for the doomed user: %v", err) + } + survivingToken, _, err := m.Issue(f.ctx, otherID, false) + if err != nil { + t.Fatalf("Issue for the surviving user: %v", err) + } + if n := f.sessionCount(t); n != 3 { + t.Fatalf("%d sessions before the delete, want 3", n) + } + + if _, err := f.pool.Exec(f.ctx, `DELETE FROM users WHERE id = $1::uuid`, f.userID); err != nil { + // A RESTRICT foreign key would fail here, which is exactly the design + // this test rules out. + t.Fatalf("delete the user: %v", err) + } + + if n := f.sessionCount(t); n != 1 { + t.Fatalf("%d sessions after deleting one of two users, want 1", n) + } + if _, err := m.Authenticate(f.ctx, doomedToken); !errors.Is(err, ErrSessionNotFound) { + t.Errorf("a deleted user's session returned %v, want ErrSessionNotFound", err) + } + if _, err := m.Authenticate(f.ctx, survivingToken); err != nil { + t.Errorf("the other user's session was destroyed too: %v", err) + } +} + +// Sweep collects the sessions nobody comes back for, by either deadline. +func TestSweepDeletesExpiredSessions(t *testing.T) { + f := newFixture(t, "session_sweep") + start := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) + now := start + m := f.manager(t, shortPolicy, &now) + + if _, _, err := m.Issue(f.ctx, f.userID, false); err != nil { // dies at +1h + t.Fatalf("Issue short: %v", err) + } + longToken, _, err := m.Issue(f.ctx, f.userID, true) // dies at +24h + if err != nil { + t.Fatalf("Issue long: %v", err) + } + + now = start.Add(90 * time.Minute) + n, err := m.Sweep(f.ctx) + if err != nil { + t.Fatalf("Sweep: %v", err) + } + if n != 1 { + t.Errorf("Sweep removed %d sessions, want 1", n) + } + if _, err := m.Authenticate(f.ctx, longToken); err != nil { + t.Errorf("Sweep removed a live session: %v", err) + } + + now = start.Add(25 * time.Hour) + if n, err = m.Sweep(f.ctx); err != nil || n != 1 { + t.Errorf("second Sweep removed %d sessions (err %v), want 1", n, err) + } + if got := f.sessionCount(t); got != 0 { + t.Errorf("%d sessions remain after the sweep, want 0", got) + } +} + +// The database is the last line of defence against storing a raw token: the +// CHECK constraint refuses anything that is not a SHA-256 hex digest, even if +// the Go guard were bypassed. +func TestDatabaseRefusesARawToken(t *testing.T) { + f := newFixture(t, "session_rawtoken") + now := time.Now().UTC() + + token, err := GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + + // Through the store: a Go error naming the mistake. + err = f.store.Create(f.ctx, &Session{ + UserID: f.userID, TokenHash: token, + ExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(2 * time.Hour), + CreatedDate: now, LastSeenAt: now, + }) + if err == nil { + t.Fatal("the store accepted a raw token as a token hash") + } + + // Straight past the store, in SQL: the constraint still refuses it. + _, err = f.pool.Exec(f.ctx, + `INSERT INTO sessions (user_id, token_hash, expires_at, absolute_expires_at) + VALUES ($1::uuid, $2::text, now() + interval '1 hour', now() + interval '2 hours')`, + f.userID, token) + if err == nil { + t.Fatal("the sessions table accepted a raw token; the CHECK constraint is not doing its job") + } + + // The same insert with a proper hash succeeds, so the constraint is not + // simply rejecting everything. + if _, err := f.pool.Exec(f.ctx, + `INSERT INTO sessions (user_id, token_hash, expires_at, absolute_expires_at) + VALUES ($1::uuid, $2::text, now() + interval '1 hour', now() + interval '2 hours')`, + f.userID, HashToken(token)); err != nil { + t.Fatalf("a well-formed session was refused: %v", err) + } + + // And the same hash cannot be stored twice: UNIQUE is what makes a token + // identify exactly one session. + if _, err := f.pool.Exec(f.ctx, + `INSERT INTO sessions (user_id, token_hash, expires_at, absolute_expires_at) + VALUES ($1::uuid, $2::text, now() + interval '1 hour', now() + interval '2 hours')`, + f.userID, HashToken(token)); err == nil { + t.Fatal("two sessions were stored with the same token hash") + } +} + +// The lifetimes are a decision, so they are asserted rather than assumed. +func TestDefaultPolicy(t *testing.T) { + p := DefaultPolicy + if p.IdleLifetime != 12*time.Hour { + t.Errorf("IdleLifetime = %v, want 12h", p.IdleLifetime) + } + if p.RememberIdleLifetime != 30*24*time.Hour { + t.Errorf("RememberIdleLifetime = %v, want 720h (30 days)", p.RememberIdleLifetime) + } + // The point of the absolute bound: it must exist and must exceed the + // window it caps, or a session could live forever. + if p.AbsoluteLifetime <= 0 || p.RememberAbsoluteLifetime <= 0 { + t.Fatal("an absolute lifetime is unset; a session could live forever") + } + if err := p.validate(); err != nil { + t.Errorf("the default policy is not valid: %v", err) + } +} + +func TestNewManagerRejectsBadInput(t *testing.T) { + if _, err := NewManager(nil, DefaultPolicy); err == nil { + t.Error("NewManager accepted a nil store") + } + bad := map[string]Policy{ + "zero": {}, + "absolute below idle": {IdleLifetime: 2 * time.Hour, AbsoluteLifetime: time.Hour, RememberIdleLifetime: time.Hour, RememberAbsoluteLifetime: time.Hour}, + "remember-me unset": {IdleLifetime: time.Hour, AbsoluteLifetime: time.Hour}, + "negative idle lifetime": {IdleLifetime: -time.Hour, AbsoluteLifetime: time.Hour, RememberIdleLifetime: time.Hour, RememberAbsoluteLifetime: time.Hour}, + } + for name, p := range bad { + if _, err := NewManager(NewPGStore(nil), p); err == nil { + t.Errorf("NewManager accepted the %s policy", name) + } + } +} diff --git a/go-api/internal/auth/store.go b/go-api/internal/auth/store.go new file mode 100644 index 0000000..b8cff0d --- /dev/null +++ b/go-api/internal/auth/store.go @@ -0,0 +1,204 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +// Querier is satisfied by *pgxpool.Pool and by pgx.Tx, so every method below +// works inside or outside a transaction. +// +// Declared here rather than imported from internal/repo: that package's +// Querier is identical, but this one keeps the authentication foundation +// independent of the resource/descriptor layer, which it otherwise shares +// nothing with. Go interfaces are structural, so both are satisfied by the +// same values. +type Querier interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row + Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) +} + +// PGStore is the sessions table. +// +// Every statement here is a constant string with bind parameters. Nothing — +// not an id, not a token hash, not a timestamp — is ever formatted into SQL. +// There is no identifier taken from a caller, so there is nothing to quote and +// nothing to escape. +type PGStore struct { + db Querier +} + +// NewPGStore builds the store over a pool or a transaction. +func NewPGStore(db Querier) *PGStore { return &PGStore{db: db} } + +// Compile-time check that the persistence layer satisfies the lifecycle's +// expectations. If Store gains a method, this line is where it is noticed. +var _ Store = (*PGStore)(nil) + +// sessionColumns is the projection every read below shares, in the order the +// scan expects. +const sessionColumns = `id::text, user_id::text, token_hash, + expires_at, absolute_expires_at, created_date, last_seen_at` + +// Create inserts a session. +// +// The id is left to the database's gen_random_uuid() default when the caller +// did not choose one, and returned so the caller's Session is complete. +// created_date and last_seen_at come from the caller rather than now(), so the +// row agrees with the deadlines the Manager computed from the same instant. +func (s *PGStore) Create(ctx context.Context, sess *Session) error { + if sess.UserID == "" { + return errors.New("auth: session user id is required") + } + // The last line of defence against writing a raw token to disk. The + // database CHECK enforces the same shape; this turns it into a Go error + // naming the actual mistake instead of a constraint violation. + if !IsTokenHash(sess.TokenHash) { + return errors.New("auth: session token_hash is not a SHA-256 hex digest") + } + + const q = `INSERT INTO sessions + (id, user_id, token_hash, expires_at, absolute_expires_at, created_date, last_seen_at) + VALUES + (COALESCE($1::uuid, gen_random_uuid()), $2::uuid, $3::text, + $4::timestamptz, $5::timestamptz, $6::timestamptz, $7::timestamptz) + RETURNING id::text` + + var id *string + if sess.ID != "" { + id = &sess.ID + } + if err := s.db.QueryRow(ctx, q, id, sess.UserID, sess.TokenHash, + sess.ExpiresAt, sess.AbsoluteExpiresAt, sess.CreatedDate, sess.LastSeenAt, + ).Scan(&sess.ID); err != nil { + return fmt.Errorf("auth: create session: %w", err) + } + return nil +} + +// FindByTokenHash reads one session by the hash of its token. +// +// The parameter is a hash, never a token: the Manager hashes before it calls +// here, so a raw secret never reaches the query layer at all. Returns +// ErrSessionNotFound when no row matches, which callers must not distinguish +// from an expired session when answering a client. +// +// Expiry is deliberately not filtered in SQL. The caller decides what an +// expired row means — Authenticate deletes it, a diagnostic might report it — +// and a WHERE clause here would collapse "revoked" and "timed out" into one +// indistinguishable answer at the wrong layer. +func (s *PGStore) FindByTokenHash(ctx context.Context, tokenHash string) (Session, error) { + if tokenHash == "" { + return Session{}, ErrEmptyToken + } + if !IsTokenHash(tokenHash) { + // A value of the wrong shape cannot match any row, and querying with + // it would be an unnecessary round trip on every malformed cookie. + return Session{}, ErrSessionNotFound + } + + const q = `SELECT ` + sessionColumns + ` FROM sessions WHERE token_hash = $1::text` + + var out Session + err := s.db.QueryRow(ctx, q, tokenHash).Scan( + &out.ID, &out.UserID, &out.TokenHash, + &out.ExpiresAt, &out.AbsoluteExpiresAt, &out.CreatedDate, &out.LastSeenAt) + if errors.Is(err, pgx.ErrNoRows) { + return Session{}, ErrSessionNotFound + } + if err != nil { + return Session{}, fmt.Errorf("auth: find session: %w", err) + } + return out, nil +} + +// Touch moves the sliding deadline and records the activity. +// +// The UPDATE is guarded by `expires_at <= absolute_expires_at` in the database +// CHECK; the Manager clamps before calling, so a violation here would mean a +// bug rather than a race. +func (s *PGStore) Touch(ctx context.Context, id string, expiresAt, lastSeenAt time.Time) error { + if id == "" { + return errors.New("auth: session id is required") + } + + const q = `UPDATE sessions + SET expires_at = $2::timestamptz, last_seen_at = $3::timestamptz + WHERE id = $1::uuid` + + tag, err := s.db.Exec(ctx, q, id, expiresAt, lastSeenAt) + if err != nil { + return fmt.Errorf("auth: touch session: %w", err) + } + if tag.RowsAffected() == 0 { + // The session was revoked between the read and this write. Reporting + // it as absent is honest; the caller treats it as a failed lookup. + return ErrSessionNotFound + } + return nil +} + +// Delete removes one session by id. Returns ErrSessionNotFound if there was +// nothing to remove; Manager.RevokeID absorbs that, because logging out of a +// session that is already gone is a success. +func (s *PGStore) Delete(ctx context.Context, id string) error { + if id == "" { + return errors.New("auth: session id is required") + } + + const q = `DELETE FROM sessions WHERE id = $1::uuid` + + tag, err := s.db.Exec(ctx, q, id) + if err != nil { + return fmt.Errorf("auth: delete session: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrSessionNotFound + } + return nil +} + +// DeleteByTokenHash removes one session by the hash of its token. This is the +// logout path: the cookie is all the client has. +func (s *PGStore) DeleteByTokenHash(ctx context.Context, tokenHash string) error { + if tokenHash == "" { + return ErrEmptyToken + } + if !IsTokenHash(tokenHash) { + return ErrSessionNotFound + } + + const q = `DELETE FROM sessions WHERE token_hash = $1::text` + + tag, err := s.db.Exec(ctx, q, tokenHash) + if err != nil { + return fmt.Errorf("auth: delete session by token: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrSessionNotFound + } + return nil +} + +// DeleteExpired removes every session past either deadline and reports the +// count. +// +// Both deadlines are tested. Filtering on expires_at alone would leave behind +// a session whose sliding window is still open but whose absolute ceiling has +// passed — precisely the row the absolute bound exists to kill. +func (s *PGStore) DeleteExpired(ctx context.Context, now time.Time) (int64, error) { + const q = `DELETE FROM sessions + WHERE expires_at <= $1::timestamptz OR absolute_expires_at <= $1::timestamptz` + + tag, err := s.db.Exec(ctx, q, now) + if err != nil { + return 0, fmt.Errorf("auth: delete expired sessions: %w", err) + } + return tag.RowsAffected(), nil +} diff --git a/go-api/internal/auth/token.go b/go-api/internal/auth/token.go new file mode 100644 index 0000000..ca474d5 --- /dev/null +++ b/go-api/internal/auth/token.go @@ -0,0 +1,74 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "regexp" +) + +// ErrEmptyToken is returned when a token is required and none was supplied. +// It is deliberately distinct from "no such session": an absent cookie is a +// different situation from a cookie that no longer matches a row. +var ErrEmptyToken = errors.New("auth: session token is empty") + +// TokenBytes is the entropy behind a session token. +// +// 32 bytes — 256 bits — is the size of the SHA-256 output it is hashed to, so +// nothing is wasted at either end, and it puts guessing a live session far +// beyond reach: an attacker who could test a billion candidates a second would +// still need on the order of 10^60 years. +// +// This is a raw byte count, not a character count. The encoded token is 43 +// characters of base64url. +const TokenBytes = 32 + +// tokenHashPattern is the exact shape stored in sessions.token_hash, and the +// same pattern the sessions_token_hash_sha256 CHECK constraint enforces in +// migration 000004. Validating here turns a database constraint violation into +// a clear Go error at the point the mistake was made. +var tokenHashPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + +// GenerateToken returns a new, cryptographically random session token. +// +// The returned string is the secret itself. It is what goes into the HttpOnly +// cookie and it must never be written to the database, to a log, or to an +// error message. Only its hash is persisted — see HashToken. +// +// base64url without padding, so the value is safe in a cookie, a header and a +// URL without escaping, and contains no '=' to be mangled by a cookie parser. +func GenerateToken() (string, error) { + buf := make([]byte, TokenBytes) + if _, err := rand.Read(buf); err != nil { + // There is no fallback. math/rand here would produce tokens an + // attacker can predict from a handful of observed sessions. + return "", fmt.Errorf("auth: read random bytes: %w", err) + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +// HashToken returns the lowercase hex SHA-256 of a session token. +// +// This is what the database stores. A plain hash — not argon2 — is the right +// choice here and the wrong one for a password, and the difference is entropy: +// a session token is 256 uniformly random bits, so there is no dictionary to +// run against it and no work factor worth paying on every single request. A +// password is chosen by a human and needs argon2id precisely because it is not. +// +// The function is pure and deterministic: the same token always hashes to the +// same string, which is what makes lookup by hash possible at all. +func HashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +// IsTokenHash reports whether s has the shape HashToken produces. +// +// Used to catch the one mistake that would be catastrophic and silent: passing +// a raw token where a hash is expected, and storing the secret in plaintext. +// A raw token is base64url and contains characters outside [0-9a-f], or is the +// wrong length, so it always fails this test. +func IsTokenHash(s string) bool { return tokenHashPattern.MatchString(s) } diff --git a/go-api/internal/auth/token_test.go b/go-api/internal/auth/token_test.go new file mode 100644 index 0000000..6623e3b --- /dev/null +++ b/go-api/internal/auth/token_test.go @@ -0,0 +1,158 @@ +package auth + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "strings" + "testing" +) + +// 6. Session tokens carry real entropy from crypto/rand. +// +// Randomness cannot be proved by a test, so this asserts the properties whose +// absence would mean the generator is broken: the full 256 bits are present, +// the output is not a constant, and the bytes are not all the same value — +// which is what a zeroed or unseeded buffer looks like. +func TestGenerateTokenIsCryptographicallyRandom(t *testing.T) { + const runs = 512 + + seen := make(map[string]struct{}, runs) + bitsSet := make([]int, 8*TokenBytes) // how often each bit position was 1 + + for i := 0; i < runs; i++ { + token, err := GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + + raw, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + t.Fatalf("token is not base64url: %v", err) + } + if len(raw) != TokenBytes { + t.Fatalf("token decodes to %d bytes, want %d", len(raw), TokenBytes) + } + // A cookie value must survive a round trip untouched: no '=' padding, + // no '+' or '/' to be re-encoded. + if strings.ContainsAny(token, "=+/") { + t.Fatalf("token contains a character that is unsafe in a cookie or URL") + } + + if _, dup := seen[token]; dup { + t.Fatalf("GenerateToken returned a duplicate within %d calls", runs) + } + seen[token] = struct{}{} + + for bit := 0; bit < 8*TokenBytes; bit++ { + if raw[bit/8]&(1<<(bit%8)) != 0 { + bitsSet[bit]++ + } + } + } + + // Each bit should be 1 about half the time. A bit that is *always* 0 or + // always 1 across 512 draws has a chance of roughly 2^-511 of being random + // and is far more likely a stuck generator. The bound is deliberately + // loose — this is a smoke test for a broken source, not a statistical + // suite, and it must never flake. + for bit, count := range bitsSet { + if count == 0 || count == runs { + t.Errorf("bit %d was constant across %d tokens; the entropy source is broken", bit, runs) + } + } + + // 256 bits is the size that makes guessing a live session hopeless. + if TokenBytes < 32 { + t.Errorf("TokenBytes = %d, want at least 32", TokenBytes) + } +} + +// 7. Two generated tokens differ. +func TestGenerateTokenReturnsDistinctValues(t *testing.T) { + a, err := GenerateToken() + if err != nil { + t.Fatalf("first: %v", err) + } + b, err := GenerateToken() + if err != nil { + t.Fatalf("second: %v", err) + } + if a == b { + t.Fatal("two consecutive tokens were identical") + } + if HashToken(a) == HashToken(b) { + t.Fatal("two distinct tokens hashed to the same value") + } +} + +// 8. Hashing is deterministic, and is genuinely SHA-256 rather than something +// that merely looks like it. +func TestHashTokenIsDeterministicSHA256(t *testing.T) { + token, err := GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + + first, second := HashToken(token), HashToken(token) + if first != second { + t.Fatal("hashing the same token twice produced different values") + } + + // Checked against the standard library directly: lookup only works if the + // value stored is exactly this. + want := sha256.Sum256([]byte(token)) + if first != hex.EncodeToString(want[:]) { + t.Fatal("HashToken does not agree with crypto/sha256") + } + + if len(first) != 64 { + t.Fatalf("hash is %d characters, want 64 hex characters", len(first)) + } + if first != strings.ToLower(first) { + t.Error("hash is not lowercase; the database CHECK requires lowercase hex") + } + // The stored value must not be the secret. + if strings.Contains(first, token) || first == token { + t.Error("the hash contains the token") + } + if HashToken(token+"x") == first { + t.Error("a different token hashed to the same value") + } + + // The empty string has a hash too — that is a property of SHA-256, not a + // licence to store one. Guarding against an empty token is the Manager's + // job, and TestManagerRejectsEmptyToken covers it. + if HashToken("") == "" { + t.Error("HashToken returned an empty string") + } +} + +// IsTokenHash is the guard that stops a raw token being written where a hash +// belongs, so it must reject every raw token and accept every real hash. +func TestIsTokenHash(t *testing.T) { + token, err := GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + + if !IsTokenHash(HashToken(token)) { + t.Error("a real hash was not recognised as one") + } + if IsTokenHash(token) { + t.Error("a raw token was accepted as a hash; this is the check that prevents storing the secret") + } + + for name, s := range map[string]string{ + "empty": "", + "too short": strings.Repeat("a", 63), + "too long": strings.Repeat("a", 65), + "uppercase": strings.ToUpper(HashToken(token)), + "non-hex": strings.Repeat("g", 64), + "trailing": HashToken(token) + "\n", + } { + if IsTokenHash(s) { + t.Errorf("%s was accepted as a token hash", name) + } + } +} diff --git a/go-api/internal/auth/users.go b/go-api/internal/auth/users.go new file mode 100644 index 0000000..301ecaa --- /dev/null +++ b/go-api/internal/auth/users.go @@ -0,0 +1,142 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// ErrUserNotFound means no user matched. Callers authenticating someone must +// answer this identically to a wrong password — see the note on Credentials. +var ErrUserNotFound = errors.New("auth: user not found") + +// StatusActive is the only users.status that may hold a session. The column's +// CHECK constraint (migration 000001) permits 'active' and 'suspended'. +const StatusActive = "active" + +// User is the part of a users row that authentication needs. +// +// Note what is absent: preferences, timestamps, legacy ids. This is not a +// general user model — the resource layer already has one — it is the set of +// facts required to answer "may this person hold a session, and whose data do +// they see". +type User struct { + ID string + OrgID string + Email string + FullName string + Role string + AccountType string + Status string + + // PasswordHash is empty when the user has never had a password set. + // migration 000001 leaves the column nullable and NULL, and the seeded + // demo user is in exactly that state until `setpassword` is run. + PasswordHash string +} + +// IsActive reports whether this user may authenticate or hold a session. +func (u User) IsActive() bool { return u.Status == StatusActive } + +// CanAuthenticate reports whether a password check is even possible. A user +// with no password hash cannot sign in, and must be refused in the same way +// and with the same timing as a wrong password. +func (u User) CanAuthenticate() bool { return u.IsActive() && u.PasswordHash != "" } + +// UserStore is the read side of authentication. +// +// Deliberately read-only apart from MarkLoggedIn: creating and editing users is +// the resource layer's job, and nothing in the sign-in path should be able to +// write a role, a status or an organization. +type UserStore interface { + // FindByEmail resolves the login identifier. Email is citext and globally + // unique (migration 000004), so this returns at most one row. + FindByEmail(ctx context.Context, email string) (User, error) + // FindByID resolves the user behind a session on every request. + FindByID(ctx context.Context, id string) (User, error) + // MarkLoggedIn records a successful sign-in. + MarkLoggedIn(ctx context.Context, id string, at time.Time) error +} + +// PGUserStore reads users from PostgreSQL. +type PGUserStore struct { + db Querier +} + +// NewPGUserStore builds the store over a pool or a transaction. +func NewPGUserStore(db Querier) *PGUserStore { return &PGUserStore{db: db} } + +var _ UserStore = (*PGUserStore)(nil) + +// userColumns is the projection both lookups share. +// +// password_hash is COALESCEd to the empty string rather than scanned into a +// *string: a NULL hash and an empty hash mean the same thing here — no password +// is set — and collapsing them at the edge means no caller has to remember to +// nil-check before handing the value to VerifyPassword. +const userColumns = `id::text, org_id::text, email::text, full_name, + role, account_type, status, COALESCE(password_hash, '')` + +func scanUser(row pgx.Row) (User, error) { + var u User + err := row.Scan(&u.ID, &u.OrgID, &u.Email, &u.FullName, + &u.Role, &u.AccountType, &u.Status, &u.PasswordHash) + if errors.Is(err, pgx.ErrNoRows) { + return User{}, ErrUserNotFound + } + if err != nil { + return User{}, err + } + return u, nil +} + +// FindByEmail looks a user up by their login identifier. +// +// The comparison is against the citext column, so it is case-insensitive: +// "Demo@Krow.app" finds the same row as "demo@krow.app", which is what a person +// typing their own address at a login form expects. Trimming is the caller's +// job and is done in the handler, where the raw input is. +func (s *PGUserStore) FindByEmail(ctx context.Context, email string) (User, error) { + if email == "" { + return User{}, ErrUserNotFound + } + const q = `SELECT ` + userColumns + ` FROM users WHERE email = $1::citext` + u, err := scanUser(s.db.QueryRow(ctx, q, email)) + if err != nil && !errors.Is(err, ErrUserNotFound) { + return User{}, fmt.Errorf("auth: find user by email: %w", err) + } + return u, err +} + +// FindByID resolves the user behind a session. +// +// This runs on every authenticated request, which is why status is read here +// rather than cached in the session row: suspending an account must take effect +// on the next request, not whenever the session happens to expire. +func (s *PGUserStore) FindByID(ctx context.Context, id string) (User, error) { + if id == "" { + return User{}, ErrUserNotFound + } + const q = `SELECT ` + userColumns + ` FROM users WHERE id = $1::uuid` + u, err := scanUser(s.db.QueryRow(ctx, q, id)) + if err != nil && !errors.Is(err, ErrUserNotFound) { + return User{}, fmt.Errorf("auth: find user by id: %w", err) + } + return u, err +} + +// MarkLoggedIn stamps last_login_at. +// +// Deliberately not part of the transaction that creates the session: a failure +// to record the timestamp is a lost diagnostic, not a reason to refuse a +// sign-in that has already succeeded on its merits. +func (s *PGUserStore) MarkLoggedIn(ctx context.Context, id string, at time.Time) error { + const q = `UPDATE users SET last_login_at = $2::timestamptz WHERE id = $1::uuid` + if _, err := s.db.Exec(ctx, q, id, at); err != nil { + return fmt.Errorf("auth: mark logged in: %w", err) + } + return nil +} diff --git a/go-api/internal/authctx/authctx.go b/go-api/internal/authctx/authctx.go new file mode 100644 index 0000000..66da635 --- /dev/null +++ b/go-api/internal/authctx/authctx.go @@ -0,0 +1,68 @@ +// Package authctx carries the authenticated identity of a request. +// +// It is the successor to the development identity that used to be injected by +// httpserver.devOrgMiddleware. The difference is not the shape — both put a +// value on the request context — but the provenance: everything here was read +// out of a server-side session row, and nothing in it can be influenced by the +// request that carries it. +// +// That is the whole point of the package existing separately from the handlers. +// A handler that wants to know who is calling has exactly one place to ask, and +// that place cannot be reached from a request body, a query string or a header. +// There is deliberately no setter that takes a user id from a client. +package authctx + +import ( + "context" + "errors" + "time" +) + +type key struct{} + +// ErrNoIdentity means a protected operation was reached without an +// authenticated identity. That is a routing or middleware bug rather than a +// client error: an unauthenticated request should have been refused before it +// got this far. +var ErrNoIdentity = errors.New("no authenticated identity in context") + +// Identity is who the request is, as resolved from the session row. +// +// Role is carried because Phase 3D will need it, and because carrying it now +// means the middleware reads it once per request instead of every future +// authorization check re-querying the user. It is NOT consulted anywhere in +// Phase 3C: authentication only. +type Identity struct { + UserID string + OrgID string + Email string + FullName string + Role string + AccountType string + Status string + + // SessionID is the row this identity came from, so logout and per-session + // diagnostics do not have to re-hash the cookie. + SessionID string + // ExpiresAt is the session's sliding deadline as of this request. + ExpiresAt time.Time +} + +// With returns a context carrying the authenticated identity. +func With(ctx context.Context, id Identity) context.Context { + return context.WithValue(ctx, key{}, id) +} + +// From reads the identity, reporting whether one was present. +func From(ctx context.Context) (Identity, bool) { + v, ok := ctx.Value(key{}).(Identity) + return v, ok && v.UserID != "" +} + +// MustFrom reads the identity or returns ErrNoIdentity. +func MustFrom(ctx context.Context) (Identity, error) { + if v, ok := From(ctx); ok { + return v, nil + } + return Identity{}, ErrNoIdentity +} diff --git a/go-api/internal/config/config.go b/go-api/internal/config/config.go new file mode 100644 index 0000000..160ddf1 --- /dev/null +++ b/go-api/internal/config/config.go @@ -0,0 +1,329 @@ +// Package config loads and validates the backend's runtime configuration. +// +// Configuration comes from the process environment. A .env file in the +// repository root is read first as a convenience for local development, and +// never overrides a variable that is already set — so an explicit +// `DATABASE_PASSWORD=… go run ./cmd/api` always wins over the file. +// +// Nothing here has a credential baked in. Load fails loudly rather than +// falling back to a default host, database or user, because a silent default +// is how a development process ends up pointed at the wrong database. +package config + +import ( + "fmt" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +// Config is the whole of the Phase 1 configuration surface. +type Config struct { + AppEnv string + Log LogConfig + HTTP HTTPConfig + DB DBConfig + Seed SeedConfig +} + +// SeedConfig locates the demo fixture. The file is generated from the frontend +// repository, so it lives beside the migrations rather than inside the Go +// module: regenerating it must not require rebuilding the binary. +type SeedConfig struct { + FixturePath string +} + +type LogConfig struct { + Level string +} + +type HTTPConfig struct { + Host string + Port int + ReadTimeout time.Duration + WriteTimeout time.Duration + IdleTimeout time.Duration + ShutdownTimeout time.Duration + + // CORSOrigins is the exact set of browser origins allowed to call the API. + // + // It exists for one reason: in local development the Vite dev server is an + // origin of its own (http://localhost:5173) and the API is another + // (http://127.0.0.1:8080), so every fetch from the frontend is + // cross-origin. Empty means CORS is off and the API answers only + // same-origin callers, which is the correct posture everywhere the + // frontend is served from the same host as the API. + // + // Origins are matched exactly and echoed back one at a time. There is no + // wildcard and no pattern: "*" would let any page on the internet read + // this API, and once authentication exists that becomes a real hole rather + // than a theoretical one. + CORSOrigins []string +} + +type DBConfig struct { + Host string + Port int + Name string + User string + Password string + Schema string + SSLMode string + MaxOpenConns int32 + MinIdleConns int32 + ConnMaxLifetime time.Duration + ConnectTimeout time.Duration + StatementTimeout time.Duration +} + +// DSN builds a libpq-style connection URL. +// +// Every component is URL-escaped: the local database is called "Krow-force", +// which is both mixed-case and hyphenated, and a password may contain anything +// at all. Escaping is not optional here. +func (d DBConfig) DSN() string { + u := &url.URL{ + Scheme: "postgres", + User: url.UserPassword(d.User, d.Password), + Host: fmt.Sprintf("%s:%d", d.Host, d.Port), + Path: "/" + d.Name, + } + q := u.Query() + q.Set("sslmode", d.SSLMode) + // Pin the schema on every connection so no query can accidentally resolve + // against a different one, and so nothing reaches for a system schema. + q.Set("search_path", d.Schema) + q.Set("connect_timeout", strconv.Itoa(int(d.ConnectTimeout.Seconds()))) + q.Set("statement_timeout", strconv.Itoa(int(d.StatementTimeout.Milliseconds()))) + u.RawQuery = q.Encode() + return u.String() +} + +// Redacted returns the DSN with the password replaced, for logs. +func (d DBConfig) Redacted() string { + u, err := url.Parse(d.DSN()) + if err != nil { + return "postgres://" + } + if _, hasPassword := u.User.Password(); hasPassword { + u.User = url.UserPassword(u.User.Username(), "xxxxx") + } + return u.String() +} + +// Load reads the environment, applies defaults and validates the result. +func Load() (*Config, error) { + loadDotEnv(".env") + + var missing []string + required := func(key string) string { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + missing = append(missing, key) + } + return v + } + + cfg := &Config{ + AppEnv: withDefault("APP_ENV", "development"), + Log: LogConfig{Level: withDefault("LOG_LEVEL", "info")}, + HTTP: HTTPConfig{ + Host: withDefault("HTTP_HOST", "127.0.0.1"), + Port: intDefault("HTTP_PORT", 8080), + ReadTimeout: durationDefault("HTTP_READ_TIMEOUT", 15*time.Second), + WriteTimeout: durationDefault("HTTP_WRITE_TIMEOUT", 30*time.Second), + IdleTimeout: durationDefault("HTTP_IDLE_TIMEOUT", 60*time.Second), + ShutdownTimeout: durationDefault("HTTP_SHUTDOWN_TIMEOUT", 10*time.Second), + CORSOrigins: corsOrigins(withDefault("APP_ENV", "development")), + }, + Seed: SeedConfig{ + FixturePath: withDefault("SEED_FIXTURE_PATH", "./seed/fixtures/seed.json"), + }, + DB: DBConfig{ + Host: required("DATABASE_HOST"), + Port: intDefault("DATABASE_PORT", 5432), + Name: required("DATABASE_NAME"), + User: required("DATABASE_USER"), + Password: os.Getenv("DATABASE_PASSWORD"), // may legitimately be empty (trust/peer auth) + Schema: withDefault("DATABASE_SCHEMA", "public"), + SSLMode: withDefault("DATABASE_SSLMODE", "disable"), + MaxOpenConns: int32(intDefault("DATABASE_MAX_OPEN_CONNS", 25)), + MinIdleConns: int32(intDefault("DATABASE_MIN_IDLE_CONNS", 2)), + ConnMaxLifetime: durationDefault("DATABASE_CONN_MAX_LIFETIME", 30*time.Minute), + ConnectTimeout: durationDefault("DATABASE_CONNECT_TIMEOUT", 5*time.Second), + StatementTimeout: durationDefault("DATABASE_STATEMENT_TIMEOUT", 10*time.Second), + }, + } + + if len(missing) > 0 { + return nil, fmt.Errorf("missing required environment variables: %s "+ + "(copy .env.example to .env and fill them in)", strings.Join(missing, ", ")) + } + if err := cfg.validate(); err != nil { + return nil, err + } + return cfg, nil +} + +func (c *Config) validate() error { + switch c.AppEnv { + case "development", "staging", "production": + default: + return fmt.Errorf("APP_ENV must be development, staging or production, got %q", c.AppEnv) + } + if c.HTTP.Port < 1 || c.HTTP.Port > 65535 { + return fmt.Errorf("HTTP_PORT out of range: %d", c.HTTP.Port) + } + if c.DB.Port < 1 || c.DB.Port > 65535 { + return fmt.Errorf("DATABASE_PORT out of range: %d", c.DB.Port) + } + // The application owns exactly one schema and it is never a system schema. + switch c.DB.Schema { + case "pg_catalog", "pg_toast", "information_schema": + return fmt.Errorf("DATABASE_SCHEMA must not be a PostgreSQL system schema, got %q", c.DB.Schema) + } + if strings.HasPrefix(c.DB.Schema, "pg_") { + return fmt.Errorf("DATABASE_SCHEMA must not start with \"pg_\", got %q", c.DB.Schema) + } + if c.DB.MinIdleConns > c.DB.MaxOpenConns { + return fmt.Errorf("DATABASE_MIN_IDLE_CONNS (%d) exceeds DATABASE_MAX_OPEN_CONNS (%d)", + c.DB.MinIdleConns, c.DB.MaxOpenConns) + } + if c.AppEnv == "production" && c.DB.SSLMode == "disable" { + return fmt.Errorf("DATABASE_SSLMODE=disable is not allowed when APP_ENV=production") + } + for _, origin := range c.HTTP.CORSOrigins { + // "*" is rejected rather than quietly honoured. The middleware echoes a + // single matched origin, so a wildcard could only ever be a + // misunderstanding of what this setting does. + if origin == "*" { + return fmt.Errorf("HTTP_CORS_ORIGINS must list explicit origins; \"*\" is not accepted") + } + if !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://") { + return fmt.Errorf("HTTP_CORS_ORIGINS entry %q must be a full origin including the scheme", origin) + } + } + return nil +} + +// devCORSOrigins are the origins the Vite dev server can occupy. Vite binds +// localhost by default and 127.0.0.1 when asked, and a browser treats those two +// as different origins, so both are listed. 4173 is `vite preview`. +var devCORSOrigins = []string{ + "http://localhost:5173", "http://127.0.0.1:5173", + "http://localhost:4173", "http://127.0.0.1:4173", +} + +// corsOrigins reads HTTP_CORS_ORIGINS, a comma-separated allowlist. +// +// The development default is the Vite dev server, because that is the whole +// point of the setting in Phase 2D. Outside development the default is empty: +// a staging or production deployment that genuinely serves its frontend from +// another origin has to say so explicitly, rather than inheriting a list of +// localhost origins nobody reviewed. +func corsOrigins(appEnv string) []string { + raw, set := os.LookupEnv("HTTP_CORS_ORIGINS") + if !set { + if appEnv == "development" { + return devCORSOrigins + } + return nil + } + var out []string + for _, part := range strings.Split(raw, ",") { + // A trailing slash makes the string unequal to the Origin header the + // browser actually sends, which fails in a way that looks like a + // server bug rather than a typo. + if o := strings.TrimRight(strings.TrimSpace(part), "/"); o != "" { + out = append(out, o) + } + } + return out +} + +func withDefault(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +func intDefault(key string, fallback int) int { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return fallback + } + n, err := strconv.Atoi(v) + if err != nil { + return fallback + } + return n +} + +func durationDefault(key string, fallback time.Duration) time.Duration { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return fallback + } + d, err := time.ParseDuration(v) + if err != nil { + return fallback + } + return d +} + +// loadDotEnv reads KEY=VALUE lines, walking up from the working directory so +// `go run ./cmd/api` finds the repository-root .env. Existing environment +// variables always win. Absence of the file is not an error. +func loadDotEnv(name string) { + dir, err := os.Getwd() + if err != nil { + return + } + for i := 0; i < 5; i++ { + path := dir + string(os.PathSeparator) + name + if data, err := os.ReadFile(path); err == nil { + applyDotEnv(string(data)) + return + } + parent := parentDir(dir) + if parent == dir { + return + } + dir = parent + } +} + +func parentDir(dir string) string { + i := strings.LastIndex(dir, string(os.PathSeparator)) + if i <= 0 { + return dir + } + return dir[:i] +} + +func applyDotEnv(content string) { + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(strings.TrimPrefix(key, "export ")) + value = strings.TrimSpace(value) + if len(value) >= 2 { + if (value[0] == '"' && value[len(value)-1] == '"') || + (value[0] == '\'' && value[len(value)-1] == '\'') { + value = value[1 : len(value)-1] + } + } + if _, present := os.LookupEnv(key); !present { + _ = os.Setenv(key, value) + } + } +} diff --git a/go-api/internal/db/db.go b/go-api/internal/db/db.go new file mode 100644 index 0000000..e6ef552 --- /dev/null +++ b/go-api/internal/db/db.go @@ -0,0 +1,122 @@ +// Package db owns the PostgreSQL connection pool and the database health check. +package db + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/krow/krow-backend/go-api/internal/config" +) + +// DB wraps the pgx pool together with the schema the application is pinned to. +type DB struct { + Pool *pgxpool.Pool + Schema string +} + +// Open builds the pool and verifies it can actually reach the database. +// +// pgxpool.New is lazy — it returns a usable pool without having connected — +// so a bad host or a wrong password would otherwise not surface until the +// first request. Acquiring and pinging once here turns a misconfiguration into +// a startup failure instead of a runtime surprise. +func Open(ctx context.Context, cfg config.DBConfig) (*DB, error) { + poolCfg, err := pgxpool.ParseConfig(cfg.DSN()) + if err != nil { + return nil, fmt.Errorf("parse database config: %w", err) + } + poolCfg.MaxConns = cfg.MaxOpenConns + poolCfg.MinIdleConns = cfg.MinIdleConns + poolCfg.MaxConnLifetime = cfg.ConnMaxLifetime + + pool, err := pgxpool.NewWithConfig(ctx, poolCfg) + if err != nil { + return nil, fmt.Errorf("create connection pool: %w", err) + } + + pingCtx, cancel := context.WithTimeout(ctx, cfg.ConnectTimeout) + defer cancel() + if err := pool.Ping(pingCtx); err != nil { + pool.Close() + return nil, fmt.Errorf("connect to %s: %w", cfg.Redacted(), err) + } + + return &DB{Pool: pool, Schema: cfg.Schema}, nil +} + +// Close releases every pooled connection. +func (d *DB) Close() { + if d != nil && d.Pool != nil { + d.Pool.Close() + } +} + +// Health is what the /health endpoint reports about the database. +type Health struct { + Reachable bool `json:"reachable"` + Error string `json:"error,omitempty"` + Version string `json:"version,omitempty"` + Database string `json:"database,omitempty"` + Schema string `json:"schema,omitempty"` + SchemaPresent bool `json:"schema_present"` + AppliedMigration *int64 `json:"applied_migration,omitempty"` + MigrationDirty bool `json:"migration_dirty"` + TableCount int `json:"table_count"` + LatencyMS int64 `json:"latency_ms"` +} + +// Check answers "can the API serve requests against this database right now". +// +// It reports more than a ping because a reachable database with no schema in it +// is a different failure from an unreachable one, and both are worth telling +// apart at a glance during Phase 1. Reads are confined to the configured schema +// via to_regclass and a count over information_schema, which is the standard +// SQL view rather than a pg_catalog table. +func (d *DB) Check(ctx context.Context) Health { + started := time.Now() + h := Health{Schema: d.Schema} + + conn, err := d.Pool.Acquire(ctx) + if err != nil { + h.Error = err.Error() + h.LatencyMS = time.Since(started).Milliseconds() + return h + } + defer conn.Release() + + if err := conn.QueryRow(ctx, + `SELECT current_database(), current_setting('server_version')`, + ).Scan(&h.Database, &h.Version); err != nil { + h.Error = err.Error() + h.LatencyMS = time.Since(started).Milliseconds() + return h + } + h.Reachable = true + + if err := conn.QueryRow(ctx, + `SELECT count(*)::int FROM information_schema.tables + WHERE table_schema = $1 AND table_type = 'BASE TABLE'`, + d.Schema, + ).Scan(&h.TableCount); err != nil { + h.Error = err.Error() + h.LatencyMS = time.Since(started).Milliseconds() + return h + } + h.SchemaPresent = h.TableCount > 0 + + // golang-migrate's bookkeeping table. Absent before the first migration, + // which is a legitimate state and not an error. + var version int64 + var dirty bool + err = conn.QueryRow(ctx, `SELECT version, dirty FROM schema_migrations LIMIT 1`).Scan(&version, &dirty) + if err == nil { + h.AppliedMigration = &version + h.MigrationDirty = dirty + } + + h.LatencyMS = time.Since(started).Milliseconds() + return h +} diff --git a/go-api/internal/definition/agent.go b/go-api/internal/definition/agent.go new file mode 100644 index 0000000..4f39797 --- /dev/null +++ b/go-api/internal/definition/agent.go @@ -0,0 +1,543 @@ +package definition + +import ( + "fmt" + "math" + "strings" +) + +// One Markdown definition → one agent. +// +// A port of parseAgent / validateAgentSource in src/lib/agents/registry.js and +// normalizeAgent in src/lib/agents/agentConfig.js. +// +// The contract normalizeAgent holds, and this holds with it: +// +// - Everything is optional. A definition declaring only an id and a name +// normalizes to a working agent with documented defaults. +// - Nothing unknown survives. Statuses, reasoning modes, pages, icons, +// knowledge kinds and permission roles are checked against the closed +// tables in vocabulary.go; an unrecognised value is a named error rather +// than a dropped key. +// - What validates is kept. One bad entry costs its author that entry and a +// message, never the rest of the file. +// +// One rule is deliberately absent, and it is absent on the frontend for the +// same reason: an agent with NO SKILLS is not refused. Five of this product's +// pages have no Owliver skills and answer from their own page responder, so +// refusing a skill-less agent would mean inventing placeholder skills to make +// those pages configurable. + +// Starter is one conversation starter. +type Starter struct { + Label string `json:"label"` + Prompt string `json:"prompt"` +} + +// Knowledge is one thing an agent has been told, as distinct from something it +// can do. Modelled as a document with an id and a body because that is the +// shape a retrieval layer reads. +type Knowledge struct { + ID string `json:"id"` + Label string `json:"label"` + Kind string `json:"kind"` + Body string `json:"body"` + URL string `json:"url"` +} + +// Person is one named grant on an agent. +type Person struct { + User string `json:"user"` + Role string `json:"role"` +} + +// Permissions is who owns an agent, who may reach it, and what they may do. +// +// Parsed and NOT enforced. Migration 000005 deliberately has no +// definition_permissions table: the block stays inside the Markdown until its +// semantics are defined. +type Permissions struct { + Owner string `json:"owner"` + Access string `json:"access"` + People []Person `json:"people"` +} + +// Agent is a definition as the backend reads it. +type Agent struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + Version int `json:"version"` + + // Pages as CANONICAL surface ids. + // + // Unlike Skill.Pages, which keeps what the author wrote. The two are + // genuinely different on the frontend — normalizeAgent maps every page + // through canonicalPage and parseSkill does not — so agent_definitions.pages + // and skill_definitions.pages hold different vocabularies for the same + // concept. Reproduced rather than reconciled: making them agree here would + // make each one disagree with its own editor. + Pages []string `json:"pages"` + + Icon string `json:"icon"` + Reasoning string `json:"reasoning"` + Trigger string `json:"trigger"` + WebSearch bool `json:"webSearch"` + + Skills []string `json:"skills"` + Subagents []string `json:"subagents"` + Starters []Starter `json:"starters"` + Knowledge []Knowledge `json:"knowledge"` + Permissions Permissions `json:"permissions"` + + // Instructions is the body's `## Instructions` section. Prose belongs under + // a heading where it can be written and read as prose, not in a + // frontmatter string. + Instructions string `json:"instructions"` + + // Errors is what this definition lost on the way in, in the order + // normalizeAgent produces them. Carried on the record rather than thrown, + // so one bad entry costs its author that entry and a message. + Errors []string `json:"errors"` + + Body string `json:"-"` +} + +// asList is agentConfig.js's own coercion: an array stays an array, nothing +// becomes nothing, and anything else becomes a list of one. +// +// This is why `pages: candidates` is accepted for an AGENT and refused for a +// SKILL — parseSkill requires a real sequence and normalizeAgent coerces. +func asList(v any) []any { + switch x := v.(type) { + case []any: + return x + case nil: + return []any{} + case string: + if x == "" { + return []any{} + } + } + return []any{v} +} + +// uniqueStrings keeps order and drops repeats; a blank entry is an error rather +// than a silent gap, because a blank id is an address that points nowhere. +func uniqueStrings(raw any, where, label string, errs *[]string) []string { + seen := map[string]bool{} + out := []string{} + for i, entry := range asList(raw) { + value := jsTrimmed(entry) + if value == "" { + *errs = append(*errs, fmt.Sprintf("%s[%d]: %s cannot be blank.", where, i, label)) + continue + } + if seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + return out +} + +// normalizePages resolves every declared page to a canonical surface key. +// +// Through CanonicalPage, so a definition may write an alias — `university` for +// `krow-forge` — exactly as a skill may. An unknown page is an error rather +// than a silently dropped entry, because a page nobody recognises is an agent +// that will never appear anywhere and give no reason why. +func normalizePages(raw any, errs *[]string) []string { + seen := map[string]bool{} + pages := []string{} + for i, entry := range asList(raw) { + written := jsTrimmed(entry) + if written == "" { + *errs = append(*errs, fmt.Sprintf("pages[%d]: a page cannot be blank.", i)) + continue + } + canonical := CanonicalPage(written) + if canonical == "" { + *errs = append(*errs, fmt.Sprintf( + "pages[%d]: `%s` is not a page this product has.", i, written)) + continue + } + if seen[canonical] { + continue + } + seen[canonical] = true + pages = append(pages, canonical) + } + return pages +} + +// normalizeStarter reads one starter, in either the plain-string or the mapping +// form. A starter with no prompt of its own asks what it says. +func normalizeStarter(raw any, index int, errs *[]string) *Starter { + where := fmt.Sprintf("starters[%d]", index) + + switch v := raw.(type) { + case string, float64: + label := jsTrimmed(v) + if label == "" { + *errs = append(*errs, where+": a starter needs text.") + return nil + } + return &Starter{Label: label, Prompt: label} + case map[string]any: + // `raw.label ?? raw.prompt` — nullish, so an absent or null label + // falls through to the prompt and a starter written as a bare prompt + // still has something to show. + source := v["label"] + if source == nil { + source = v["prompt"] + } + label := jsTrimmed(source) + if label == "" { + *errs = append(*errs, where+": a starter needs a `label`.") + return nil + } + prompt := jsTrimmed(v["prompt"]) + if prompt == "" { + prompt = label + } + return &Starter{Label: label, Prompt: prompt} + } + + *errs = append(*errs, where+": a starter must be a line of text, or a mapping of options.") + return nil +} + +// normalizeKnowledge reads one knowledge entry. +func normalizeKnowledge(raw any, index int, errs *[]string) *Knowledge { + where := fmt.Sprintf("knowledge[%d]", index) + + switch v := raw.(type) { + case string, float64: + body := jsTrimmed(v) + if body == "" { + *errs = append(*errs, where+": a knowledge entry needs text.") + return nil + } + id := slugify(runeSlice(body, 40)) + if id == "" { + id = fmt.Sprintf("k%d", index+1) + } + return &Knowledge{ + ID: id, Label: runeSlice(body, 60), Kind: DefaultKnowledgeKind, Body: body, + } + case map[string]any: + label := jsTrimmed(v["label"]) + body := jsTrimmed(v["body"]) + url := jsTrimmed(v["url"]) + + if label == "" && body == "" { + *errs = append(*errs, where+": a knowledge entry needs a `label` or a `body`.") + return nil + } + + kind := jsTrimmed(v["kind"]) + if kind == "" { + kind = DefaultKnowledgeKind + } + if !contains(KnowledgeKinds, kind) { + *errs = append(*errs, fmt.Sprintf( + "%s: `%s` is not a knowledge kind. Use one of %s.", + where, kind, strings.Join(KnowledgeKinds, ", "))) + return nil + } + if kind == "link" && url == "" { + *errs = append(*errs, where+": a `link` needs a `url`.") + return nil + } + + id := jsTrimmed(v["id"]) + if id == "" { + id = slugify(label) + } + if id == "" { + id = fmt.Sprintf("k%d", index+1) + } + if label == "" { + label = runeSlice(body, 60) + } + return &Knowledge{ID: id, Label: label, Kind: kind, Body: body, URL: url} + } + + *errs = append(*errs, where+": a knowledge entry must be a line of text, or a mapping of options.") + return nil +} + +// runeSlice is JavaScript's String.prototype.slice(0, n), which counts UTF-16 +// units. Counting runes instead differs only for astral characters, and cutting +// a surrogate pair in half — which the frontend can do — would produce a label +// no comparison could match. Runes are used deliberately; the conformance suite +// carries no case that distinguishes them. +func runeSlice(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) +} + +// normalizePermissions reads the `permissions:` block. +func normalizePermissions(raw any, errs *[]string) Permissions { + none := Permissions{Access: DefaultAgentAccess, People: []Person{}} + if raw == nil { + return none + } + + mapping, ok := raw.(map[string]any) + if !ok { + *errs = append(*errs, "permissions: must be a mapping of `owner`, `access` and `people`.") + return none + } + + access := jsTrimmed(mapping["access"]) + if access == "" { + access = DefaultAgentAccess + } + if !contains(AgentAccess, access) { + *errs = append(*errs, fmt.Sprintf( + "permissions.access: `%s` is not an access mode. Use one of %s.", + access, strings.Join(AgentAccess, ", "))) + } + + people := []Person{} + for i, entry := range asList(mapping["people"]) { + where := fmt.Sprintf("permissions.people[%d]", i) + person, ok := entry.(map[string]any) + if !ok { + *errs = append(*errs, where+": must be a mapping of `user` and `role`.") + continue + } + user := jsTrimmed(person["user"]) + if user == "" { + *errs = append(*errs, where+": needs a `user`.") + continue + } + role := jsTrimmed(person["role"]) + if role == "" { + role = DefaultPermission + } + if !contains(PermissionRole, role) { + *errs = append(*errs, fmt.Sprintf( + "%s: `%s` is not a role. Use one of %s.", + where, role, strings.Join(PermissionRole, ", "))) + continue + } + people = append(people, Person{User: user, Role: role}) + } + + result := Permissions{Owner: jsTrimmed(mapping["owner"]), Access: access, People: people} + if !contains(AgentAccess, access) { + result.Access = DefaultAgentAccess + } + return result +} + +// ParseAgent reads an agent definition. +// +// The order in which errors accumulate is part of the contract: validateAgent +// reports the FIRST one, so a definition with two problems must name the same +// one the editor names. That order is status, reasoning, icon, version, +// subagents, starters, knowledge, pages, skills, permissions — which is +// evaluation order in normalizeAgent, counting the object literal it returns. +func ParseAgent(raw string, opts Options) (*Agent, error) { + doc, err := ParseFrontmatter(raw) + if err != nil { + return nil, err + } + data := doc.Data + errs := []string{} + + id := jsTrim(jsString(data["id"])) + if !jsTruthy(data["id"]) { + id = slugify(data["name"]) + if id == "" { + id = fileStem(opts.path()) + } + } + + status := jsTrimmed(data["status"]) + if status == "" { + status = DefaultAgentStatus + } + if !contains(AgentStatuses, status) { + errs = append(errs, fmt.Sprintf("status: `%s` is not a status. Use one of %s.", + status, strings.Join(AgentStatuses, ", "))) + } + + reasoning := jsTrimmed(data["reasoning"]) + if reasoning == "" { + reasoning = DefaultReasoning + } + if !contains(ReasoningModes, reasoning) { + errs = append(errs, fmt.Sprintf("reasoning: `%s` is not a reasoning mode. Use one of %s.", + reasoning, strings.Join(ReasoningModes, ", "))) + } + + icon := jsTrimmed(data["icon"]) + if icon == "" { + icon = DefaultAgentIcon + } + if !contains(AgentIcons, icon) { + errs = append(errs, fmt.Sprintf("icon: `%s` is not an icon this product has.", icon)) + } + + // A version is an integer that only ever goes up. Anything else is an + // authoring slip, and reading it as 1 is kinder than refusing the file — + // but it is still reported, because a definition that thinks it is v3 and + // registers as v1 will publish over something. + version := 1 + if v, present := data["version"]; present && v != nil && v != "" { + parsed := jsNumber(v) + if math.IsNaN(parsed) || parsed != math.Trunc(parsed) || math.IsInf(parsed, 0) || parsed < 1 { + errs = append(errs, fmt.Sprintf( + "version: `%s` is not a whole number of 1 or more.", jsString(v))) + } else if parsed > maxExactInteger { + // Beyond 2^53-1 a float64 no longer names one integer, so there is + // no value to carry. Saturating keeps the conversion below defined, + // and ValidateAgent refuses everything above MaxVersion anyway, so + // a saturated version can never reach a column. + version = maxExactInteger + } else { + version = int(parsed) + } + } + + subagents := uniqueStrings(data["subagents"], "subagents", "a subagent id", &errs) + kept := subagents[:0] + for _, s := range subagents { + if id != "" && s == id { + errs = append(errs, "subagents: an agent cannot be its own subagent.") + continue + } + kept = append(kept, s) + } + subagents = kept + + starters := []Starter{} + for i, entry := range asList(data["starters"]) { + if s := normalizeStarter(entry, i, &errs); s != nil { + starters = append(starters, *s) + } + } + + knowledge := []Knowledge{} + for i, entry := range asList(data["knowledge"]) { + if k := normalizeKnowledge(entry, i, &errs); k != nil { + knowledge = append(knowledge, *k) + } + } + + // From here the order follows the object literal normalizeAgent returns. + pages := normalizePages(data["pages"], &errs) + skills := uniqueStrings(data["skills"], "skills", "a skill id", &errs) + permissions := normalizePermissions(data["permissions"], &errs) + + instructions, _ := sectionSource(doc.Body, "Instructions") + + agent := &Agent{ + ID: id, + Name: "Untitled agent", + Status: status, + Version: version, + Pages: pages, + Icon: icon, + Reasoning: reasoning, + Trigger: jsTrimmed(data["trigger"]), + WebSearch: data["webSearch"] == true || data["web_search"] == true, + Skills: skills, + Subagents: subagents, + Starters: starters, + Knowledge: knowledge, + Permissions: permissions, + Instructions: jsTrim(instructions), + Errors: errs, + Body: doc.Body, + } + + if jsTruthy(data["name"]) { + agent.Name = jsString(data["name"]) + } + if jsTruthy(data["description"]) { + agent.Description = jsString(data["description"]) + } + if !contains(AgentStatuses, status) { + agent.Status = DefaultAgentStatus + } + if !contains(ReasoningModes, reasoning) { + agent.Reasoning = DefaultReasoning + } + if !contains(AgentIcons, icon) { + agent.Icon = DefaultAgentIcon + } + + return agent, nil +} + +// ValidateAgent decides whether an agent definition may be stored. +// +// Returns nil when it may. The order is the order an author would fix things +// in, which is why it reads the same way ValidateSkill does. Note that the +// `id` message differs from the skill one by two words — that difference is +// the frontend's, and it is reproduced rather than tidied. +func ValidateAgent(raw string) error { + if jsTrim(raw) == "" { + return &Rejection{Message: "Paste or upload a Markdown definition."} + } + + if n := len([]rune(raw)); n > MaxMarkdownLength { + return &Rejection{ + BackendOnly: true, + Message: fmt.Sprintf( + "That definition is %d characters. The limit is %d.", n, MaxMarkdownLength), + } + } + + agent, err := ParseAgent(raw, Options{}) + if err != nil { + return &Rejection{Message: jsTrim("That definition could not be parsed. " + err.Error())} + } + + if agent.ID == "" { + return &Rejection{Message: "The frontmatter needs an `id`."} + } + if !isDefinitionID(agent.ID) { + return &Rejection{Message: "The `id` must be lower-case letters, numbers and dashes."} + } + // `!raw.includes('name:') || agent.name === 'Untitled agent'` — the literal + // substring test is the frontend's, and it is why a definition whose name + // resolves to the fallback is refused even when some other key happens to + // spell `name:`. + if !strings.Contains(raw, "name:") || agent.Name == "Untitled agent" { + return &Rejection{Message: "The frontmatter needs a `name`."} + } + if len(agent.Pages) == 0 { + return &Rejection{Message: "An agent needs at least one `pages:` entry, or it can never be offered anywhere."} + } + if len(agent.Errors) > 0 { + return &Rejection{Message: agent.Errors[0]} + } + + // The backend's own bound, the companion to the size rule above: + // agent_definitions.version is a PostgreSQL `integer`, and the frontend + // accepts any whole number of 1 or more. It is checked here rather than in + // ParseAgent so the normalized record stays identical to the frontend's for + // every definition the frontend accepts, and last among the rules so a + // definition the frontend also refuses is refused with the frontend's own + // message. + if agent.Version > MaxVersion { + return &Rejection{ + BackendOnly: true, + Message: fmt.Sprintf( + "version: `%d` is larger than %d.", agent.Version, MaxVersion), + } + } + + return nil +} diff --git a/go-api/internal/definition/conformance_test.go b/go-api/internal/definition/conformance_test.go new file mode 100644 index 0000000..a466649 --- /dev/null +++ b/go-api/internal/definition/conformance_test.go @@ -0,0 +1,928 @@ +package definition_test + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "reflect" + "sort" + "strconv" + "strings" + "testing" + + "github.com/krow/krow-backend/go-api/internal/definition" +) + +// Phase 4D — JS/Go parser conformance. +// +// The fixture these tests read (testdata/oracle.json) is not written by hand. +// It is captured by scripts/oracle.mjs, which loads the REAL frontend module +// graph through Vite — import.meta.glob, the `@/` alias and raw Markdown +// loading all behave exactly as they do in the app — and records what the +// JavaScript parser did with every shipped definition and every adversarial +// case. So the assertion below is not "Go agrees with a description of the +// frontend"; it is "Go agrees with the frontend", replayed. +// +// Regenerate after any change to src/lib/skills or src/lib/agents: +// +// node scripts/oracle.mjs go-api/internal/definition/testdata/oracle.json +// +// A frontend change that alters parsing therefore fails these tests, which is +// the point: the contract cannot drift silently in either direction. + +type oracle struct { + Vocabulary struct { + Pages []struct { + ID string `json:"id"` + Aliases []string `json:"aliases"` + } `json:"pages"` + AgentStatuses []string `json:"agentStatuses"` + Reasoning []string `json:"reasoning"` + Icons []string `json:"icons"` + KnowledgeKinds []string `json:"knowledgeKinds"` + Access []string `json:"access"` + Roles []string `json:"roles"` + } `json:"vocabulary"` + Corpus []observation `json:"corpus"` + Cases []observation `json:"cases"` +} + +// observation is one definition as the JavaScript saw it, end to end. +type observation struct { + // Exactly one of these identifies the row. + Path string `json:"path"` + Name string `json:"name"` + Type string `json:"type"` + + Kind string `json:"kind"` // agent | skill + RawBase64 string `json:"rawBase64"` + + HasFrontmatter bool `json:"hasFrontmatter"` + + Frontmatter struct { + OK bool `json:"ok"` + Data map[string]any `json:"data"` + Body string `json:"body"` + Error string `json:"error"` + } `json:"frontmatter"` + + Parse struct { + OK bool `json:"ok"` + Error string `json:"error"` + } `json:"parse"` + + Normalized map[string]any `json:"normalized"` + + Accepted bool `json:"accepted"` + Rejection *string `json:"rejection"` +} + +func (o observation) id() string { + if o.Path != "" { + return o.Path + } + return o.Name +} + +func (o observation) raw(t *testing.T) string { + t.Helper() + b, err := base64.StdEncoding.DecodeString(o.RawBase64) + if err != nil { + t.Fatalf("%s: undecodable fixture: %v", o.id(), err) + } + return string(b) +} + +func load(t *testing.T) *oracle { + t.Helper() + b, err := os.ReadFile("testdata/oracle.json") + if err != nil { + t.Fatalf("read fixture: %v", err) + } + var o oracle + if err := json.Unmarshal(b, &o); err != nil { + t.Fatalf("parse fixture: %v", err) + } + if len(o.Corpus) == 0 || len(o.Cases) == 0 { + t.Fatal("fixture is empty; regenerate with scripts/oracle.mjs") + } + return &o +} + +func all(o *oracle) []observation { return append(append([]observation{}, o.Corpus...), o.Cases...) } + +/* ── 1. The corpus is the corpus ──────────────────────────────────────────── */ + +// The shipped definition count, asserted rather than assumed. A definition +// added to or removed from the product without regenerating the fixture leaves +// these tests passing against a corpus that no longer exists, which is the one +// way this suite could quietly stop meaning anything. +func TestCorpusShape(t *testing.T) { + o := load(t) + + counts := map[string]int{} + for _, c := range o.Corpus { + counts[c.Type]++ + } + + for _, want := range []struct { + kind string + n int + }{{"agent", 9}, {"skill", 23}, {"example", 5}} { + if counts[want.kind] != want.n { + t.Errorf("%s definitions: got %d, want %d", want.kind, counts[want.kind], want.n) + } + } + if len(o.Corpus) != 37 { + t.Errorf("shipped definitions: got %d, want 37", len(o.Corpus)) + } +} + +/* ── 2. The vocabulary has not drifted ────────────────────────────────────── */ + +// Every closed table in vocabulary.go, checked against the table the frontend +// actually exports. A page added to surfaces.js fails here rather than becoming +// a definition the editor accepts and the API rejects. +func TestVocabularyMatchesFrontend(t *testing.T) { + o := load(t) + + wantPages := make([]string, len(o.Vocabulary.Pages)) + for i, p := range o.Vocabulary.Pages { + wantPages[i] = p.ID + } + if !reflect.DeepEqual(definition.SupportedPages, wantPages) { + t.Errorf("supported pages differ\n go %v\n js %v", definition.SupportedPages, wantPages) + } + + // Aliases resolve, and resolve to the same canonical id. + for _, p := range o.Vocabulary.Pages { + for _, alias := range append([]string{p.ID}, p.Aliases...) { + if got := definition.CanonicalPage(alias); got != p.ID { + t.Errorf("CanonicalPage(%q) = %q, want %q", alias, got, p.ID) + } + } + } + + for _, table := range []struct { + name string + got []string + wanted []string + }{ + {"agent statuses", definition.AgentStatuses, o.Vocabulary.AgentStatuses}, + {"reasoning modes", definition.ReasoningModes, o.Vocabulary.Reasoning}, + {"icons", definition.AgentIcons, o.Vocabulary.Icons}, + {"knowledge kinds", definition.KnowledgeKinds, o.Vocabulary.KnowledgeKinds}, + {"access modes", definition.AgentAccess, o.Vocabulary.Access}, + {"permission roles", definition.PermissionRole, o.Vocabulary.Roles}, + } { + if !reflect.DeepEqual(table.got, table.wanted) { + t.Errorf("%s differ\n go %v\n js %v", table.name, table.got, table.wanted) + } + } +} + +/* ── 3. The frontmatter tree ──────────────────────────────────────────────── */ + +// The deepest parity check available: the YAML subset must produce the same +// data structure the JavaScript produced, for every definition and every +// adversarial case. Not a projection of it — the whole tree. +func TestFrontmatterTreeParity(t *testing.T) { + for _, c := range all(load(t)) { + t.Run(c.id(), func(t *testing.T) { + raw := c.raw(t) + doc, err := definition.ParseFrontmatter(raw) + + if !c.Frontmatter.OK { + if err == nil { + t.Fatalf("JS refused this frontmatter (%s); Go accepted it", c.Frontmatter.Error) + } + if err.Error() != c.Frontmatter.Error { + t.Errorf("error text differs\n go %q\n js %q", err.Error(), c.Frontmatter.Error) + } + return + } + if err != nil { + t.Fatalf("JS read this frontmatter; Go refused it: %v", err) + } + + if got, want := normalizeTree(doc.Data), normalizeTree(c.Frontmatter.Data); !reflect.DeepEqual(got, want) { + t.Errorf("frontmatter differs\n go %s\n js %s", show(got), show(want)) + } + if doc.Body != c.Frontmatter.Body { + t.Errorf("body differs\n go %q\n js %q", doc.Body, c.Frontmatter.Body) + } + if got := definition.HasFrontmatter(raw); got != c.HasFrontmatter { + t.Errorf("HasFrontmatter = %v, JS said %v", got, c.HasFrontmatter) + } + }) + } +} + +// normalizeTree puts a parsed tree into the shape `encoding/json` would have +// produced, so the Go value and the value round-tripped through the fixture's +// JSON are comparable. Numbers become float64 on both sides, which is what +// JavaScript had in the first place. +func normalizeTree(v any) any { + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("unmarshalable: %v", err) + } + var out any + if err := json.Unmarshal(b, &out); err != nil { + return fmt.Sprintf("unmarshalable: %v", err) + } + return out +} + +func show(v any) string { + b, _ := json.Marshal(v) + return string(b) +} + +/* ── 4. Accept / reject parity ────────────────────────────────────────────── */ + +// knownDivergence is the complete list of definitions where the two parsers +// disagree, each with the reason. It is a CLOSED list: anything not on it that +// disagrees fails, and anything on it that stops disagreeing fails too, so the +// list cannot quietly grow and cannot quietly go stale. +// +// Every entry is a case where Go is stricter, except the first — and the first +// is the one asymmetry this package documents as deferred. +var knownDivergence = map[string]string{ + "skill-examples/board-invalid-context.md": "" + + "JS rejects on `ui:` placement/source semantics, which this package defers " + + "to the frontend. Go accepts and reports Deferred: [ui].", + + "oversized-markdown": "" + + "JS accepts; the database refuses it (markdown_size CHECK). Go refuses it " + + "first, so an author gets a message instead of a constraint violation.", + "oversized-agent": "" + + "JS accepts; the database refuses it (markdown_size CHECK). Go refuses it " + + "first, so an author gets a message instead of a constraint violation.", + + "version-above-int32-agent": "" + + "JS accepts any whole number of 1 or more; agent_definitions.version is a " + + "PostgreSQL `integer`, so the database refuses this one. Go refuses it " + + "first, for the same reason as the size bound.", +} + +func TestAcceptanceParity(t *testing.T) { + seen := map[string]bool{} + + for _, c := range all(load(t)) { + t.Run(c.id(), func(t *testing.T) { + raw := c.raw(t) + + var err error + if c.Kind == "agent" { + err = definition.ValidateAgent(raw) + } else { + err = definition.ValidateSkill(raw) + } + accepted := err == nil + + if reason, expected := knownDivergence[c.id()]; expected { + seen[c.id()] = true + if accepted == c.Accepted { + t.Errorf("listed as a known divergence but the two now agree (%v).\n"+ + "Remove it from knownDivergence.\n reason on file: %s", accepted, reason) + } + return + } + + if accepted != c.Accepted { + t.Fatalf("acceptance differs: go=%v js=%v\n go said: %v\n js said: %v", + accepted, c.Accepted, err, deref(c.Rejection)) + } + }) + } + + for id := range knownDivergence { + if !seen[id] { + t.Errorf("knownDivergence names %q, which is not in the fixture", id) + } + } +} + +// Where both refuse a definition, they must refuse it for the same stated +// reason. A parser that rejects the right definitions with the wrong messages +// sends an author to the wrong line. +func TestRejectionMessageParity(t *testing.T) { + for _, c := range all(load(t)) { + if c.Accepted || c.Rejection == nil { + continue + } + if _, skip := knownDivergence[c.id()]; skip { + continue + } + t.Run(c.id(), func(t *testing.T) { + raw := c.raw(t) + var err error + if c.Kind == "agent" { + err = definition.ValidateAgent(raw) + } else { + err = definition.ValidateSkill(raw) + } + if err == nil { + t.Fatalf("JS rejected this; Go accepted it") + } + if r, ok := err.(*definition.Rejection); ok && r.BackendOnly { + t.Fatalf("refused by a backend-only rule where JS refused it too: %q", r.Message) + } + if err.Error() != *c.Rejection { + t.Errorf("rejection differs\n go %q\n js %q", err.Error(), *c.Rejection) + } + }) + } +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} + +/* ── 5. Normalized projection parity ──────────────────────────────────────── */ + +// textCoercion is the complete list of fixture cases where the JavaScript +// record holds a value that is NOT a string in a field migration 000005 +// projects into a `text` or `text[]` column, named field by field. +// +// The frontend can afford this and the backend cannot. `name: [a, b]` leaves a +// JavaScript ARRAY on skill.name, and every consumer stringifies it at the +// point of use — the trigger list on that very record reads "a,b", which is +// String(["a","b"]). A text column has no such option: something must be +// written, once, at the boundary. This package writes String(x), which is the +// string the frontend's own consumers produce. +// +// Listing them rather than coercing everywhere is the point. Coercion is +// applied ONLY to the fields named here, so a genuine difference between two +// strings still fails; each entry is checked to be still necessary, so the list +// cannot go stale; and an unlisted case that needs coercion fails outright, so +// the list cannot quietly grow. It is the same closed-list discipline +// knownDivergence has, for the same reason. +var textCoercion = map[string][]string{ + "invalid-field-type-name-list": {"name"}, + "name-list-agent": {"name"}, + "name-numeric": {"name"}, + "name-boolean": {"name"}, + "description-list": {"description"}, + "description-numeric": {"description"}, + "pages-numeric-entry": {"pages"}, + "pages-mapping-entry": {"pages"}, +} + +// The two shapes a text projection can have, named rather than inferred. +// +// Which one applies is a property of the COLUMN, not of what the author +// happened to write. `name` is `text`, so a sequence written there becomes one +// string — String(["a","b"]) is "a,b". `pages` is `text[]`, so a sequence stays +// a sequence and each entry becomes a string of its own. Inferring the shape +// from whatever Go produced would make the test agree with the parser by +// construction, which is the one thing it must not do. +var textScalarFields = map[string]bool{ + "name": true, "description": true, "category": true, "trigger": true, "prompt": true, +} + +var textListFields = map[string]bool{ + "pages": true, "actions": true, "triggers": true, "skills": true, "subagents": true, +} + +// jsText is String(x) for a value decoded from the fixture's JSON — the same +// conversion jsvalue.go performs inside the parser, restated here so the test +// does not have to reach into the package it is testing to check it. +func jsText(t *testing.T, field string, v any) any { + t.Helper() + + switch { + case textScalarFields[field]: + return jsScalarText(v) + case textListFields[field]: + list, ok := v.([]any) + if !ok { + return jsScalarText(v) + } + out := make([]any, len(list)) + for i, item := range list { + out[i] = jsScalarText(item) + } + return out + } + + t.Fatalf("textCoercion names %q, which is not a text-projected field", field) + return nil +} + +func jsScalarText(v any) string { + switch x := v.(type) { + case nil: + return "null" + case bool: + if x { + return "true" + } + return "false" + case float64: + if x == float64(int64(x)) { + return strconv.FormatInt(int64(x), 10) + } + return strconv.FormatFloat(x, 'g', -1, 64) + case string: + return x + case []any: + // Array.prototype.toString: nil renders as the empty string, not + // "null", which is the one place the two differ. + parts := make([]string, len(x)) + for i, item := range x { + if item == nil { + continue + } + parts[i] = jsScalarText(item) + } + return strings.Join(parts, ",") + case map[string]any: + return "[object Object]" + } + return "" +} + +// The fields migration 000005 projects into columns, compared for every +// definition both parsers accept. These are the values that reach the +// database, so a difference here is a row the frontend would render wrongly. +func TestProjectionParity(t *testing.T) { + fixture := map[string]bool{} + + for _, c := range all(load(t)) { + fixture[c.id()] = true + if c.Normalized == nil { + continue // JS could not parse it; covered by the tree test + } + coerce := map[string]bool{} + for _, field := range textCoercion[c.id()] { + coerce[field] = true + } + + t.Run(c.id(), func(t *testing.T) { + raw := c.raw(t) + + if c.Kind == "agent" { + agent, err := definition.ParseAgent(raw, definition.Options{}) + if err != nil { + t.Fatalf("JS parsed this; Go refused it: %v", err) + } + compare(t, map[string]any{ + "id": agent.ID, + "name": agent.Name, + "description": agent.Description, + "status": agent.Status, + "version": agent.Version, + "pages": agent.Pages, + "icon": agent.Icon, + "reasoning": agent.Reasoning, + "trigger": agent.Trigger, + "webSearch": agent.WebSearch, + "skills": agent.Skills, + "subagents": agent.Subagents, + "starters": agent.Starters, + "permissions": agent.Permissions, + "errors": agent.Errors, + }, c.Normalized, coerce) + return + } + + skill, err := definition.ParseSkill(raw, definition.Options{}) + if err != nil { + t.Fatalf("JS parsed this; Go refused it: %v", err) + } + compare(t, map[string]any{ + "id": skill.ID, + "name": skill.Name, + "description": skill.Description, + "status": skill.Status, + "pages": skill.Pages, + "kind": skill.Kind, + "category": skill.Category, + "actions": skill.Actions, + "triggers": skill.Triggers, + "declaredTriggers": skill.DeclaredTriggers, + "prompt": skill.Prompt, + "skillId": skill.SkillID, + }, c.Normalized, coerce) + }) + } + + for id := range textCoercion { + if !fixture[id] { + t.Errorf("textCoercion names %q, which is not in the fixture", id) + } + } +} + +// compare checks every field Go produced against the JS record, field by field +// so a failure names the field rather than dumping two objects. +func compare(t *testing.T, got map[string]any, want map[string]any, coerce map[string]bool) { + t.Helper() + + keys := make([]string, 0, len(got)) + for k := range got { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + wantValue, present := want[k] + if !present { + t.Errorf("%s: absent from the JS record", k) + continue + } + if coerce[k] { + // Listed in textCoercion. Check the entry is still earning its + // place before honouring it: if the JS value is already the string + // Go produced, the coercion is doing nothing and the list has gone + // stale. + coerced := jsText(t, k, normalizeTree(wantValue)) + if reflect.DeepEqual(normalizeTree(wantValue), normalizeTree(coerced)) { + t.Errorf("%s: listed in textCoercion, but the JS value is already "+ + "a string. Remove the entry.", k) + } + wantValue = coerced + } + + g, w := normalizeTree(got[k]), normalizeTree(wantValue) + // An empty list and a missing one are the same thing to both parsers. + if isEmptyList(g) && isEmptyList(w) { + continue + } + if !reflect.DeepEqual(g, w) { + t.Errorf("%s differs\n go %s\n js %s", k, show(g), show(w)) + } + } +} + +func isEmptyList(v any) bool { + if v == nil { + return true + } + l, ok := v.([]any) + return ok && len(l) == 0 +} + +/* ── 6. The parser never rewrites what is stored ──────────────────────────── */ + +// Migration 000005 keeps `markdown` verbatim and derives every other column +// from it. Parsing must therefore be a read: normalization exists to +// INTERPRET a definition, never to rewrite it. +func TestParsingDoesNotMutateSource(t *testing.T) { + for _, c := range all(load(t)) { + raw := c.raw(t) + before := string(append([]byte{}, raw...)) + + _, _ = definition.ParseSkill(raw, definition.Options{}) + _, _ = definition.ParseAgent(raw, definition.Options{}) + _ = definition.ValidateSkill(raw) + _ = definition.ValidateAgent(raw) + + if raw != before { + t.Fatalf("%s: the source changed under the parser", c.id()) + } + } +} + +// Normalize is what the parser reads THROUGH; what it returns must never be +// what gets stored. Asserted directly, because the whole separation rests on +// it: the corpus contains definitions whose normalized form differs from their +// stored form, and storing the normalized one would silently rewrite an +// author's file. +func TestNormalizationIsNotStorage(t *testing.T) { + rewritten := 0 + for _, c := range all(load(t)) { + raw := c.raw(t) + if definition.Normalize(raw) != raw { + rewritten++ + } + } + if rewritten == 0 { + t.Fatal("no case in the corpus is changed by Normalize; " + + "this test can no longer tell storage and interpretation apart") + } + t.Logf("%d of %d definitions normalize to something other than their stored bytes", rewritten, len(all(load(t)))) +} + +/* ── 7. Adversarial coverage is real ──────────────────────────────────────── */ + +// The adversarial cases Phase 4D requires, each mapped to the fixture rows that +// exercise it. A case list that drifts away from the requirement is a suite +// that looks thorough and tests something else. +func TestAdversarialCoverage(t *testing.T) { + required := map[string][]string{ + "UTF-8 BOM": {"utf8-bom", "utf8-bom-agent", "bom-crlf-blankline"}, + "CRLF": {"crlf", "crlf-agent", "crlf-inside-frontmatter-only"}, + "CR": {"cr-only"}, + "leading blank line": {"leading-blank-line"}, + "multiple leading blank lines": {"multiple-leading-blank-lines", "leading-spaces-then-blank-lines"}, + "trailing spaces": {"trailing-spaces-on-values"}, + "trailing newline": {"many-trailing-newlines", "no-trailing-newline"}, + "trailing ws after fence": {"trailing-ws-after-open-fence", "trailing-tab-after-close-fence"}, + "quoted scalar": {"double-quoted-scalar", "doubled-quote-escape"}, + "single-quoted scalar": {"single-quoted-scalar"}, + "colon inside quoted string": {"colon-in-quoted-string", "colon-in-unquoted-string"}, + "hash inside quoted string": {"hash-in-quoted-string", "hash-unquoted-trailing-comment", "hash-unquoted-midword"}, + "empty scalar": {"empty-scalar", "tilde-scalar", "null-scalar"}, + "empty array": {"empty-array"}, + "inline array": {"inline-flow-array", "inline-flow-map"}, + "multiline scalar": {"block-scalar-literal", "block-scalar-folded"}, + "duplicate key": {"duplicate-key", "duplicate-key-array"}, + "malformed YAML": {"malformed-yaml-bare-line", "key-with-space", "ragged-indent"}, + "malformed opening fence": {"malformed-open-fence-two-dashes", "malformed-open-fence-four-dashes", + "malformed-open-fence-indented", "malformed-open-fence-text-after"}, + "malformed closing fence": {"malformed-close-fence-two-dashes", "malformed-close-fence-missing", + "malformed-close-fence-four-dashes"}, + "missing frontmatter": {"missing-frontmatter", "empty-fence-pair", "frontmatter-is-a-sequence"}, + "unsupported frontmatter field": {"unsupported-frontmatter-field", "unsupported-field-agent", "uppercase-key"}, + "invalid field type": {"invalid-field-type-pages-scalar", "invalid-field-type-pages-scalar-agent", + "invalid-field-type-name-list"}, + "invalid definition id": {"invalid-definition-id-uppercase", "invalid-definition-id-leading-dash", + "invalid-definition-id-underscore"}, + "invalid status": {"invalid-status-skill", "invalid-status-agent", "inactive-status-skill"}, + "invalid visibility": {"visibility-field-personal", "visibility-field-invalid"}, + "oversized markdown": {"oversized-markdown", "oversized-agent", "at-size-bound"}, + "empty markdown": {"empty-markdown", "whitespace-only-markdown"}, + } + + present := map[string]bool{} + for _, c := range load(t).Cases { + present[c.Name] = true + } + + for requirement, names := range required { + for _, n := range names { + if !present[n] { + t.Errorf("%q: the fixture has no case named %q", requirement, n) + } + } + } +} + +/* ── 8. Deferred blocks are reported, not assumed ─────────────────────────── */ + +// Every skill carrying a `ui:` or `owliver:` block must say so, because that is +// the one part of validation this package does not do. A block that stopped +// being reported would be a gap nobody could see. +func TestDeferredBlocksAreReported(t *testing.T) { + o := load(t) + found := 0 + + for _, c := range append(append([]observation{}, o.Corpus...), o.Cases...) { + if c.Kind != "skill" || !c.Frontmatter.OK { + continue + } + want := []string{} + for _, key := range []string{"ui", "owliver"} { + if _, present := c.Frontmatter.Data[key]; present { + want = append(want, key) + } + } + + skill, err := definition.ParseSkill(c.raw(t), definition.Options{}) + if err != nil { + continue + } + if len(want) == 0 { + if len(skill.Deferred) != 0 { + t.Errorf("%s: reported Deferred %v with no such block", c.id(), skill.Deferred) + } + continue + } + found++ + if !reflect.DeepEqual(skill.Deferred, want) { + t.Errorf("%s: Deferred = %v, want %v", c.id(), skill.Deferred, want) + } + } + + if found != 19 { + t.Errorf("definitions carrying a deferred block: got %d, want 19", found) + } +} + +/* ── 9. Mutation checks ───────────────────────────────────────────────────── */ + +// Tests that pass against a broken parser are not tests. Each mutation below +// is a plausible mistake in this package; every one must be caught by a real +// definition changing its meaning, not by an assertion written to notice it. +func TestMutationsWouldBeCaught(t *testing.T) { + base := strings.Join([]string{ + "---", + "id: sample-skill", + "name: Sample Skill", + "description: A sample.", + "pages:", + " - candidates", + "---", + "", + "# Sample Skill", + }, "\n") + + mutations := []struct { + name string + raw string + check func(t *testing.T, s *definition.Skill, err error) + }{ + { + // Dropping the BOM strip: the fence stops matching and every field + // empties out. + name: "BOM before the fence still fences", + raw: "\uFEFF" + base, + check: func(t *testing.T, s *definition.Skill, err error) { + if err != nil || s.ID != "sample-skill" || len(s.Pages) != 1 { + t.Errorf("got id=%q pages=%v err=%v", s.ID, s.Pages, err) + } + }, + }, + { + // Dropping CR normalization: `candidates\r` is not a page. + name: "CRLF endings do not leak into values", + raw: strings.ReplaceAll(base, "\n", "\r\n"), + check: func(t *testing.T, s *definition.Skill, err error) { + if err != nil || len(s.Pages) != 1 || s.Pages[0] != "candidates" { + t.Errorf("got pages=%v err=%v", s.Pages, err) + } + }, + }, + { + // Trimming the closing fence too eagerly, or not at all. + name: "trailing tab after the closing fence still closes it", + raw: strings.Replace(base, "\n---\n", "\n---\t\n", 1), + check: func(t *testing.T, s *definition.Skill, err error) { + if err != nil || s.Name != "Sample Skill" { + t.Errorf("got name=%q err=%v", s.Name, err) + } + }, + }, + { + // A greedy fence would swallow the second document and lose the id. + name: "a second --- document is body, not frontmatter", + raw: base + "\n\n---\nid: second\n---\n", + check: func(t *testing.T, s *definition.Skill, err error) { + if err != nil || s.ID != "sample-skill" { + t.Errorf("got id=%q err=%v", s.ID, err) + } + }, + }, + { + // Treating `#` as always starting a comment. + name: "a hash inside a word is part of the word", + raw: strings.Replace(base, "description: A sample.", "category: ops#1", 1), + check: func(t *testing.T, s *definition.Skill, err error) { + if err != nil || s.Category != "ops#1" { + t.Errorf("got category=%q err=%v", s.Category, err) + } + }, + }, + { + // Treating a spaced `#` as part of the value. + name: "a spaced hash starts a comment", + raw: strings.Replace(base, "description: A sample.", "category: ops # note", 1), + check: func(t *testing.T, s *definition.Skill, err error) { + if err != nil || s.Category != "ops" { + t.Errorf("got category=%q err=%v", s.Category, err) + } + }, + }, + { + // Splitting a quoted value on its colon. + name: "a colon inside quotes stays in the value", + raw: strings.Replace(base, "name: Sample Skill", `name: "Sample: Skill"`, 1), + check: func(t *testing.T, s *definition.Skill, err error) { + if err != nil || s.Name != "Sample: Skill" { + t.Errorf("got name=%q err=%v", s.Name, err) + } + }, + }, + { + // Keeping the first duplicate rather than the last. + name: "a duplicate key takes the last value", + raw: strings.Replace(base, "name: Sample Skill", "name: First\nname: Second", 1), + check: func(t *testing.T, s *definition.Skill, err error) { + if err != nil || s.Name != "Second" { + t.Errorf("got name=%q err=%v", s.Name, err) + } + }, + }, + { + // Accepting ragged indentation instead of refusing it. + name: "ragged indentation is refused with its line", + raw: strings.Replace(base, " - candidates", " - candidates\n - positions", 1), + check: func(t *testing.T, s *definition.Skill, err error) { + var pe *definition.Error + if err == nil { + t.Fatalf("accepted ragged indentation: %+v", s) + } + if !asError(err, &pe) || pe.Line != 6 { + t.Errorf("got %v, want an *Error on line 6", err) + } + }, + }, + { + // Canonicalising a skill's pages, which the frontend does not do. + name: "a skill keeps the page name as written", + raw: strings.Replace(base, " - candidates", " - Talent Pool", 1), + check: func(t *testing.T, s *definition.Skill, err error) { + if err != nil || len(s.Pages) != 1 || s.Pages[0] != "Talent Pool" { + t.Errorf("got pages=%v err=%v", s.Pages, err) + } + if err := definition.ValidateSkill(strings.Replace(base, " - candidates", " - Talent Pool", 1)); err != nil { + t.Errorf("an aliased page should still validate: %v", err) + } + }, + }, + } + + for _, m := range mutations { + t.Run(m.name, func(t *testing.T) { + skill, err := definition.ParseSkill(m.raw, definition.Options{}) + if skill == nil { + skill = &definition.Skill{} + } + m.check(t, skill, err) + }) + } +} + +// asError is errors.As, spelled out for the one concrete type this package +// returns. +func asError(err error, target **definition.Error) bool { + e, ok := err.(*definition.Error) + if ok { + *target = e + } + return ok +} + +/* ── 10. Bounds ───────────────────────────────────────────────────────────── */ + +// The size bound is the backend's, and both directions of it matter: a +// definition at the limit must be storable and one character more must not. +// The companion to TestSizeBound, for the other backend-only bound. The +// fixture pins a version well above the bound and one exactly at it, which +// leaves the step between them untested — an off-by-one there would refuse a +// version PostgreSQL can store, or accept one it cannot. Both sides of the +// step are named here so that cannot happen. +func TestVersionBound(t *testing.T) { + agent := func(version string) string { + return "---\nid: sample-agent\nname: Sample Agent\npages:\n - candidates\n" + + "version: " + version + "\n---\n\n# Sample Agent\n" + } + + at := strconv.Itoa(definition.MaxVersion) + if err := definition.ValidateAgent(agent(at)); err != nil { + t.Errorf("version %s, exactly at the bound, was refused: %v", at, err) + } + + over := strconv.FormatInt(int64(definition.MaxVersion)+1, 10) + err := definition.ValidateAgent(agent(over)) + if err == nil { + t.Fatalf("version %s, one past the bound, was accepted", over) + } + r, ok := err.(*definition.Rejection) + if !ok || !r.BackendOnly { + t.Errorf("the version bound should be reported as a backend-only rule, got %v", err) + } + + // The bound belongs to validation, not to parsing: a version the database + // cannot store must still normalize to the number the author wrote, or the + // record the editor shows and the record Go builds would disagree. + parsed, err := definition.ParseAgent(agent(over), definition.Options{}) + if err != nil { + t.Fatalf("parsing a too-large version failed: %v", err) + } + if got := strconv.Itoa(parsed.Version); got != over { + t.Errorf("parse clamped the version to %s; it should carry %s", got, over) + } + if len(parsed.Errors) != 0 { + t.Errorf("parse reported a backend-only bound as an authoring error: %v", parsed.Errors) + } +} + +func TestSizeBound(t *testing.T) { + head := "---\nid: sample-skill\nname: Sample Skill\npages:\n - candidates\n---\n\n" + + at := head + strings.Repeat("y", definition.MaxMarkdownLength-len(head)) + if n := len([]rune(at)); n != definition.MaxMarkdownLength { + t.Fatalf("fixture is %d characters, wanted exactly %d", n, definition.MaxMarkdownLength) + } + if err := definition.ValidateSkill(at); err != nil { + t.Errorf("a definition exactly at the bound was refused: %v", err) + } + + over := at + "y" + err := definition.ValidateSkill(over) + if err == nil { + t.Fatal("a definition one character over the bound was accepted") + } + r, ok := err.(*definition.Rejection) + if !ok || !r.BackendOnly { + t.Errorf("the size bound should be reported as a backend-only rule, got %v", err) + } +} diff --git a/go-api/internal/definition/definition.go b/go-api/internal/definition/definition.go new file mode 100644 index 0000000..ca38454 --- /dev/null +++ b/go-api/internal/definition/definition.go @@ -0,0 +1,102 @@ +// Package definition reads Krow agent and skill definitions — Markdown with +// YAML frontmatter — the way the frontend reads them. +// +// # Why this exists +// +// A definition is authored in the browser and stored by the server, so two +// parsers see it: the JavaScript in src/lib/skills and src/lib/agents, and +// this one. If they disagree, one of two things happens, and both are silent: +// +// - A definition the editor accepts and this package rejects looks valid +// while it is being written and fails when it is saved. +// - A definition this package accepts and the editor rejects is stored and +// then cannot be rendered by the product that owns it. +// +// Compatibility is therefore a contract rather than an aspiration, and it is +// enforced by a conformance suite (conformance_test.go) that replays the +// ACTUAL output of the JavaScript parser — captured from the real frontend +// module graph — against this one, over all 37 shipped definitions and every +// adversarial case in testdata/oracle.json. The corpus count is pinned by a +// test; the case count is deliberately not restated here, because a number +// kept in a comment is a number that goes stale. +// +// # What is in the contract +// +// - The document layer: byte-order mark, line endings, leading blank lines, +// fence recognition, body extraction. See frontmatter.go. +// - The YAML subset: block maps and sequences, scalars, quoting, comments. +// A port of yaml.js, with no YAML dependency, deliberately — see yaml.go. +// - Definition-level normalization and validation: id, name, description, +// status, version, pages, icons, reasoning, permissions, starters, +// knowledge, subagents. +// +// Those cover every column migration 000005 projects out of a definition: +// definition_id, status, version, name, description, pages. +// +// # What is deferred, and why +// +// A skill may carry a `ui:` block (declarative page sections) or an `owliver:` +// block (assistant capabilities). Validating those means reproducing roughly +// 1,500 lines of closed vocabulary describing what the FRONTEND can render — +// placements, data sources, section types, periods — none of which the backend +// stores, projects, or acts on. +// +// This package therefore does not check them. It records their presence on +// Skill.Deferred instead, so the gap is a value a caller can see rather than +// an assumption. The one consequence is stated exactly: +// +// skill-examples/board-invalid-context.md is rejected by the frontend, on a +// rule about which placement can supply which data source, and accepted +// here. It is the only definition in the corpus where the two disagree, and +// the conformance suite asserts that it stays the only one. +// +// # What is not the parser's job +// +// Normalization never rewrites what is stored. Migration 000005 keeps +// `markdown` verbatim and every other column is derived from it; this package +// only ever reads. The Markdown handed in is the Markdown that goes to the +// database, byte for byte, and a test asserts it. +// +// Visibility (personal or organization) is deliberately absent. It is not a +// frontmatter field — the frontend ignores `visibility:` in a definition +// entirely — it is a storage tier chosen by the request and checked by the +// visibility CHECK in migration 000005. A definition cannot name its own +// tenancy. +package definition + +// MaxMarkdownLength is the markdown_size CHECK from migration 000005, in +// CHARACTERS — `length()` in PostgreSQL counts characters, not bytes. +// +// The frontend does NOT enforce this, so a definition longer than this is one +// the editor accepts and the database refuses. This package refuses it first, +// which turns a constraint violation into a message an author can act on. +const MaxMarkdownLength = 65536 + +// MaxVersion is the range of agent_definitions.version, a PostgreSQL +// `integer`. +// +// The frontend accepts any integer of 1 or more, so a version above this is +// another value the editor accepts and the database cannot store. +const MaxVersion = 2147483647 + +// maxExactInteger is 2^53-1, the largest integer a float64 names exactly and so +// the largest a JavaScript number carries without loss. It bounds the version +// conversion in ParseAgent; it is not a rule about what may be stored, which is +// MaxVersion's job. +const maxExactInteger = 1<<53 - 1 + +// Rejection is a definition that parses but may not be stored. +// +// Message is the frontend's own wording wherever the rule is shared, so the +// editor and the API describe the same problem the same way. +type Rejection struct { + Message string + + // BackendOnly marks a rule the frontend does not have — a bound the + // database imposes that the editor never checks. These are the only + // messages that can differ from what an author would see in the browser, + // and each one is listed in docs/phase-4d-parser-contract.md. + BackendOnly bool +} + +func (r *Rejection) Error() string { return r.Message } diff --git a/go-api/internal/definition/frontmatter.go b/go-api/internal/definition/frontmatter.go new file mode 100644 index 0000000..b2b4b3c --- /dev/null +++ b/go-api/internal/definition/frontmatter.go @@ -0,0 +1,332 @@ +package definition + +import ( + "strings" +) + +// The document layer: what is frontmatter, what is body, and what a `## Heading` +// section contains. +// +// A port of the four exported readers in src/lib/skills/registry.js — +// normalizeDefinition, hasFrontmatter, parseFrontmatter and the section +// readers. Agent and skill definitions are read by the same code on the +// frontend, deliberately, so that the two formats cannot drift; the same is +// true here. +// +// The regular expressions the JavaScript uses are hand-rolled rather than +// translated, because two of them rely on lookahead and lazy matching that RE2 +// does not have. Each is written out below with the JavaScript it reproduces. + +// Normalize is the frontend's `normalizeDefinition`: a definition's text as the +// parser needs to see it. +// +// Files arrive from editors, from Windows, from copy-paste and from downloads, +// and four of the things they arrive with used to take the whole frontmatter +// block down — a UTF-8 byte-order mark before the opening fence, blank lines +// above it, CRLF endings, and trailing spaces after `---`. In each case the +// fence did not match and the definition registered as untitled with no pages. +// +// This is NOT a lenient parser. The subset inside the fences is exactly as +// strict as it was. This is only about recognising that a fence is a fence. +// +// String(raw ?? '') +// .replace(/^\uFEFF/, '') +// .replace(/\r\n?/g, '\n') +// .replace(/^\s*\n+/, '') +// +// The order is load bearing: the BOM goes first so it cannot be counted as the +// leading whitespace, and CR normalisation goes before the blank-line strip so +// that a CRLF blank line is one. +func Normalize(raw string) string { + text := strings.TrimPrefix(raw, "\uFEFF") + + // `\r\n?` → `\n`: a CRLF pair and a lone CR both become one newline. + if strings.IndexByte(text, '\r') >= 0 { + var b strings.Builder + b.Grow(len(text)) + for i := 0; i < len(text); i++ { + if text[i] != '\r' { + b.WriteByte(text[i]) + continue + } + b.WriteByte('\n') + if i+1 < len(text) && text[i+1] == '\n' { + i++ + } + } + text = b.String() + } + + // `^\s*\n+` → ``. Greedy `\s*` then at least one newline: the effect is to + // drop the leading whitespace run up to and including its LAST newline, and + // to drop nothing at all when that run contains no newline. A definition + // indented by one space is therefore still unfenced, which is what the + // editor decides too. + end, last := 0, -1 + for i, r := range text { + if !jsIsSpace(r) { + break + } + if r == '\n' { + last = i + } + end = i + len(string(r)) + } + _ = end + if last >= 0 { + text = text[last+1:] + } + + return text +} + +// fence locates the frontmatter block in already-normalized text. +// +// /^---[ \t]*\n([\s\S]*?)\n---[ \t]*(?=\n|$)/ +// +// Returns the YAML source, the offset just past the closing fence, and whether +// there was one. Lazy: the FIRST closing fence wins, which is why a definition +// carrying a second `---` document keeps only the first and reads the rest as +// body. +func fence(text string) (yaml string, end int, ok bool) { + if !strings.HasPrefix(text, "---") { + return "", 0, false + } + i := 3 + for i < len(text) && (text[i] == ' ' || text[i] == '\t') { + i++ + } + if i >= len(text) || text[i] != '\n' { + return "", 0, false + } + start := i + 1 + + for at := start - 1; at >= 0 && at < len(text); { + nl := strings.IndexByte(text[at+1:], '\n') + if nl < 0 { + return "", 0, false + } + at = at + 1 + nl // index of the newline that must precede the fence + + rest := text[at+1:] + if !strings.HasPrefix(rest, "---") { + continue + } + j := 3 + for j < len(rest) && (rest[j] == ' ' || rest[j] == '\t') { + j++ + } + // `(?=\n|$)` — end of the document, or the end of this line. `$` has no + // multiline flag on the frontend either, so it means end of document. + if j < len(rest) && rest[j] != '\n' { + continue + } + return text[start:at], at + 1 + j, true + } + return "", 0, false +} + +// HasFrontmatter reports whether this text opens with a frontmatter block at +// all. It does not say whether that block parses. +func HasFrontmatter(raw string) bool { + _, _, ok := fence(Normalize(raw)) + return ok +} + +// Document is a definition split into its two halves. +type Document struct { + // Data is the frontmatter as plain data. Always a mapping: a frontmatter + // block that parses to a sequence is discarded, exactly as the frontend + // discards it, because every reader downstream indexes it by key. + Data map[string]any + + // Body is everything after the closing fence, trimmed. A document with no + // frontmatter is all body. + Body string + + // Fenced records whether a frontmatter block was found, which Data alone + // cannot express — an empty fence pair and a missing one both give an + // empty mapping. + Fenced bool +} + +// ParseFrontmatter splits a definition and reads its frontmatter. +// +// Returns a *Error when the YAML subset refuses a line. A document with no +// recognisable fence is NOT an error: it is a document with no frontmatter, +// and what happens to it is the validator's decision — the same division the +// frontend makes. +func ParseFrontmatter(raw string) (Document, error) { + text := Normalize(raw) + + yaml, end, ok := fence(text) + if !ok { + return Document{Data: map[string]any{}, Body: text, Fenced: false}, nil + } + + value, err := ParseYAML(yaml) + if err != nil { + return Document{}, err + } + + data, _ := value.(map[string]any) + if data == nil { + data = map[string]any{} + } + + return Document{Data: data, Body: jsTrim(text[end:]), Fenced: true}, nil +} + +// sectionSource is the text under a `## Heading`, up to the next one. +// +// new RegExp(`##\\s+${escaped}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i') +// +// Case-insensitive, and deliberately not anchored to the start of a line — +// that is what the frontend does. The heading is compared literally rather +// than compiled into a pattern, which is the same protection the frontend gets +// by escaping it: a heading containing regular-expression punctuation must +// match the words it is built from. +// +// Returns ok=false for a section that is not there, which is a different thing +// from a section that is there and empty. +func sectionSource(body, heading string) (string, bool) { + lower := strings.ToLower(body) + want := strings.ToLower(heading) + + for at := 0; ; { + h := strings.Index(lower[at:], "##") + if h < 0 { + return "", false + } + h += at + at = h + 2 + + // `##` then `\s+` then the heading. + i := h + 2 + gap := i + for i < len(body) { + r, size := decodeRune(body[i:]) + if !jsIsSpace(r) { + break + } + i += size + } + if i == gap { + continue // `\s+` needs at least one + } + if !strings.HasPrefix(lower[i:], want) { + continue + } + i += len(want) + + // `\s*\n`: a whitespace run that ends in a newline. + j, nl := i, -1 + for j < len(body) { + r, size := decodeRune(body[j:]) + if !jsIsSpace(r) { + break + } + if r == '\n' { + nl = j + break + } + j += size + } + if nl < 0 { + continue + } + + start := nl + 1 + // `(?=\n##\s|$)`, lazily: the first following line that opens a new + // `##` heading. `###` does not, because the character after `##` must + // be whitespace. + for k := start; ; { + n := strings.Index(body[k:], "\n##") + if n < 0 { + return body[start:], true + } + n += k + after := n + 3 + if after < len(body) { + r, _ := decodeRune(body[after:]) + if jsIsSpace(r) { + return body[start:n], true + } + } + k = n + 1 + } + } +} + +// decodeRune is utf8.DecodeRuneInString, kept local so the section reader has +// one obvious way to step through the body. +func decodeRune(s string) (rune, int) { + for i, r := range s { + _ = i + return r, len(string(r)) + } + return 0, 0 +} + +// SectionText is the prose under a `## Heading`, with its bullets and blank +// lines flattened to one line. +// +// Used for a workforce level's description, which is a sentence rather than a +// list. +func SectionText(body, heading string) string { + source, ok := sectionSource(body, heading) + if !ok { + return "" + } + parts := []string{} + for _, l := range strings.Split(source, "\n") { + l = jsTrim(stripListMarker(l)) + if l == "" { + continue + } + parts = append(parts, l) + } + return jsTrim(strings.Join(parts, " ")) +} + +// stripListMarker removes a leading `-`, `*` or `1.` / `1)` bullet. +// +// /^\s*(?:[-*]|\d+[.)])\s+/ +func stripListMarker(l string) string { + i := 0 + for i < len(l) { + r, size := decodeRune(l[i:]) + if !jsIsSpace(r) { + break + } + i += size + } + marker := i + switch { + case i < len(l) && (l[i] == '-' || l[i] == '*'): + i++ + default: + digits := i + for i < len(l) && l[i] >= '0' && l[i] <= '9' { + i++ + } + if i == digits || i >= len(l) || (l[i] != '.' && l[i] != ')') { + return l + } + i++ + } + // `\s+` after the marker is required; without it there is no list item. + space := i + for i < len(l) { + r, size := decodeRune(l[i:]) + if !jsIsSpace(r) { + break + } + i += size + } + if i == space { + return l + } + _ = marker + return l[i:] +} diff --git a/go-api/internal/definition/jsvalue.go b/go-api/internal/definition/jsvalue.go new file mode 100644 index 0000000..de68ad2 --- /dev/null +++ b/go-api/internal/definition/jsvalue.go @@ -0,0 +1,210 @@ +package definition + +import ( + "math" + "strconv" + "strings" +) + +// JavaScript value semantics, reproduced exactly. +// +// The frontend parser is JavaScript, and the compatibility contract is with +// THAT parser, not with an idealised YAML. Three of its behaviours are load +// bearing and none of them are Go's defaults: +// +// - `\s` and `String.prototype.trim` cover a different set of code points +// than `unicode.IsSpace`. JS treats U+FEFF as whitespace and U+0085 as +// not; Go is the other way round. A definition is trimmed on the way +// through the parser at least four times, so the difference is reachable. +// - Truthiness decides whether `id:` is used or derived, whether a name +// falls back to `Untitled skill`, and what `prompt:` becomes. `0`, `false` +// and `""` are falsy; `"0"` and `[]` are not. +// - `String(x)` and `Number(x)` have defined results for every type, and the +// validator interpolates them into messages an author reads. `[1,2]` +// stringifies to `1,2`, an object to `[object Object]`. +// +// Reimplementing these is not gold-plating: each one is exercised by a case in +// the conformance suite because each one is reachable from a definition an +// author could write. + +// jsIsSpace reports whether r is whitespace to JavaScript — the union of +// WhiteSpace and LineTerminator in the specification. +// +// Deliberately NOT unicode.IsSpace: that set includes U+0085 (NEL), which JS +// does not, and excludes U+FEFF, which JS does. +func jsIsSpace(r rune) bool { + switch r { + case '\t', '\n', '\v', '\f', '\r', ' ', + 0x00A0, 0x1680, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000, 0xFEFF: + return true + } + return r >= 0x2000 && r <= 0x200A +} + +// jsTrim is String.prototype.trim. +func jsTrim(s string) string { return strings.TrimFunc(s, jsIsSpace) } + +// jsTrimStart is String.prototype.trimStart. +func jsTrimStart(s string) string { return strings.TrimLeftFunc(s, jsIsSpace) } + +// jsTruthy is the `!!x` of a parsed YAML value. +// +// The parser produces only nil, bool, float64, string, []any and +// map[string]any, so those are the only cases that can arise. An empty array +// and an empty object are both truthy in JavaScript, which is why they are not +// listed alongside the empty string. +func jsTruthy(v any) bool { + switch x := v.(type) { + case nil: + return false + case bool: + return x + case float64: + return x != 0 && !math.IsNaN(x) + case string: + return x != "" + default: + return true + } +} + +// jsNumberToString is JavaScript's Number → String conversion for the values +// this parser can produce. +// +// `-0` prints as `0`, integers print without a decimal point, and everything +// else takes the shortest representation that round-trips — which is what +// strconv's 'g' with precision -1 gives, in the range a definition can reach. +func jsNumberToString(f float64) string { + switch { + case math.IsNaN(f): + return "NaN" + case math.IsInf(f, 1): + return "Infinity" + case math.IsInf(f, -1): + return "-Infinity" + case f == 0: + return "0" // collapses -0 + } + if f == math.Trunc(f) && math.Abs(f) < 1e21 { + return strconv.FormatFloat(f, 'f', -1, 64) + } + return strconv.FormatFloat(f, 'g', -1, 64) +} + +// jsString is the `String(x)` of a parsed YAML value. +// +// Arrays join on `,` with nil rendering as the empty string, which is +// Array.prototype.toString; a mapping renders as `[object Object]`. Both are +// reachable: `trigger:` may be written as a list, and the message an author +// reads interpolates the result. +func jsString(v any) string { + switch x := v.(type) { + case nil: + return "null" + case bool: + if x { + return "true" + } + return "false" + case float64: + return jsNumberToString(x) + case string: + return x + case []any: + parts := make([]string, len(x)) + for i, item := range x { + if item == nil { + parts[i] = "" + continue + } + parts[i] = jsString(item) + } + return strings.Join(parts, ",") + case map[string]any: + return "[object Object]" + } + return "" +} + +// jsTrimmed is the frontend's `trimmed()` helper: String(value ?? empty).trim(). +// +// The nullish coalescing matters: a nil renders as the empty string here, +// where a bare String(null) would render as the four characters `null`. +func jsTrimmed(v any) string { + if v == nil { + return "" + } + return jsTrim(jsString(v)) +} + +// jsNumber is the `Number(x)` of a parsed YAML value, NaN where JavaScript +// gives NaN. +// +// Only reached from `version:`, where the result is checked with +// Number.isInteger. The string cases below are the ones a YAML scalar can +// still be carrying at that point: a quoted `"3"` stays a string, and so does +// anything the numeric patterns in toScalar declined. +func jsNumber(v any) float64 { + switch x := v.(type) { + case nil: + return 0 + case bool: + if x { + return 1 + } + return 0 + case float64: + return x + case string: + return jsNumberFromString(x) + case []any: + // Number([]) is 0 and Number([3]) is 3, via the same String() + // conversion; anything longer stringifies with a comma and fails. + if len(x) == 0 { + return 0 + } + if len(x) == 1 { + return jsNumberFromString(jsString(x[0])) + } + } + return math.NaN() +} + +func jsNumberFromString(s string) float64 { + s = jsTrim(s) + if s == "" { + return 0 + } + switch s { + case "Infinity", "+Infinity": + return math.Inf(1) + case "-Infinity": + return math.Inf(-1) + } + // The radix prefixes JavaScript accepts in a numeric string literal. Signs + // are not permitted with them, which ParseUint enforces by rejecting the + // leading character. + if len(s) > 2 && s[0] == '0' { + var base int + switch s[1] { + case 'x', 'X': + base = 16 + case 'o', 'O': + base = 8 + case 'b', 'B': + base = 2 + } + if base != 0 { + n, err := strconv.ParseUint(s[2:], base, 64) + if err != nil { + return math.NaN() + } + return float64(n) + } + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return math.NaN() + } + return f +} diff --git a/go-api/internal/definition/skill.go b/go-api/internal/definition/skill.go new file mode 100644 index 0000000..73958a7 --- /dev/null +++ b/go-api/internal/definition/skill.go @@ -0,0 +1,349 @@ +package definition + +import ( + "fmt" + "strings" +) + +// One Markdown definition → one skill. +// +// A port of parseSkill and validateSkillSource in src/lib/skills/registry.js, +// restricted to the definition contract — see the package documentation in +// definition.go for exactly where that boundary is and why the `ui:` and +// `owliver:` blocks are on the other side of it. + +// Level is one rung of a workforce ladder, read from the body's own headings. +type Level struct { + Level string `json:"level"` + Label string `json:"label"` + Summary string `json:"summary"` +} + +// Skill is a definition as the backend reads it. +// +// The five fields migration 000005 projects into columns — ID, Name, +// Description, Status, Pages — are the compatibility contract; the rest is +// carried because it is free once the frontmatter is parsed and because the +// conformance suite compares it. +type Skill struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + + // Pages as the AUTHOR WROTE THEM, not canonicalised. + // + // This is not an oversight and must not be "fixed": parseSkill keeps the + // declared strings, so a skill written against `Talent Pool` is registered + // under `Talent Pool` and resolved through normalizeKey at every use. + // Agents are the other way round — see Agent.Pages. Canonicalising here + // would make the backend's projection disagree with the editor's. + Pages []string `json:"pages"` + + Kind string `json:"kind"` + Category string `json:"category"` + Actions []string `json:"actions"` + Triggers []string `json:"triggers"` + DeclaredTriggers bool `json:"declaredTriggers"` + Prompt *string `json:"prompt"` + SkillID *string `json:"skillId"` + Levels []Level `json:"levels"` + + // Body is the Markdown after the frontmatter, trimmed. The definition + // itself is NEVER rewritten — see Definition.Markdown. + Body string `json:"-"` + + // Deferred names the frontmatter blocks whose semantics this package does + // not check and the frontend does. Empty for every definition the backend + // can fully validate on its own. See package documentation. + Deferred []string `json:"deferred,omitempty"` +} + +// AuthoredPath is the origin an authored definition has when the caller names +// none. It is a value rather than an absence for one reason: it is the +// frontend's own default parameter. +// +// parseSkill(raw, { path = 'custom', custom = false } = {}) +// parseAgent(raw, { path = 'custom', custom = false } = {}) +// +// validateSkillSource and validateAgentSource both call their parser with no +// path, so every definition the EDITOR checks derives its last-resort id from +// the literal string `custom`. That is the same call the backend is making — a +// definition submitted to the API is authored, not shipped — so the backend +// must derive the same id. +// +// The difference is reachable and it is not cosmetic. A definition with no +// `id:`, no `name:` and a valid `pages:` list gets the id `custom` on the +// frontend, passes the id-format check and is ACCEPTED. Deriving no id here +// would refuse it with "The frontmatter needs an `id`." — a definition that +// validates in the editor and fails on save, which is the exact failure mode +// this package exists to prevent. Fixture case: id-omitted-unnamed. +const AuthoredPath = "custom" + +// Options carries what the caller knows that the definition does not. +type Options struct { + // Path is the definition's origin, used only as the last fallback for an + // id. Leave it empty for anything authored rather than shipped — which is + // what the backend always has — and it becomes AuthoredPath, exactly as the + // frontend's default parameter does. + Path string +} + +// path is the origin an id is derived from, with the frontend's default +// applied. +func (o Options) path() string { + if o.Path == "" { + return AuthoredPath + } + return o.Path +} + +// ParseSkill reads a skill definition. +// +// Returns a *Error when the frontmatter cannot be read. A definition with no +// frontmatter at all is not an error here: it parses to a skill carrying the +// derived id and no pages, and ValidateSkill is what refuses it — the same +// division of labour the frontend has. +func ParseSkill(raw string, opts Options) (*Skill, error) { + doc, err := ParseFrontmatter(raw) + if err != nil { + return nil, err + } + data := doc.Data + + declaredPages, _ := data["pages"].([]any) + + // `id:` always wins. An explicit id is the address other definitions and + // stored preferences refer to, and deriving over the top of one would + // silently rename a skill. Slugging the name is what an author means by + // leaving it out; the filename is right only for a file, which is why it + // is last. + id := jsTrim(jsString(data["id"])) + if !jsTruthy(data["id"]) { + id = slugify(data["name"]) + if id == "" { + id = fileStem(opts.path()) + } + } + + levels := sectionLevels(doc.Body) + + // Two things wear the same format. A definition that names a ladder is a + // workforce skill; nothing else distinguishes them, so an author declares + // one by writing one rather than by setting a flag. + kind := jsTrim(jsString(data["kind"])) + if !jsTruthy(data["kind"]) { + kind = "assistant" + if len(levels) > 0 { + kind = "workforce" + } + } + + skill := &Skill{ + ID: id, + Kind: kind, + Levels: levels, + Body: doc.Body, + Name: "Untitled skill", + Pages: stringsOf(declaredPages), + } + + if jsTruthy(data["name"]) { + skill.Name = jsString(data["name"]) + } + if jsTruthy(data["description"]) { + skill.Description = jsString(data["description"]) + } + if s, ok := data["category"].(string); ok { + skill.Category = jsTrim(s) + } + + // The whole of a skill's lifecycle, and deliberately a coercion rather + // than a check: the frontend reads anything that is not `inactive` as + // `active`, so `status: bogus` registers as active rather than being + // refused. Reproduced, not corrected — see the divergence note in + // docs/phase-4d-parser-contract.md. + skill.Status = "active" + if s, ok := data["status"].(string); ok && s == "inactive" { + skill.Status = "inactive" + } + + if actions, ok := data["actions"].([]any); ok { + skill.Actions = stringsOf(actions) + } else { + skill.Actions = []string{} + } + + // A skill with no declared triggers answers to its own name, so a + // definition that omits the field is still reachable by asking for it. + // Explicit triggers replace the fallback rather than adding to it. + triggers, hasTriggers := data["triggers"].([]any) + skill.DeclaredTriggers = hasTriggers && len(triggers) > 0 + skill.Triggers = []string{} + if skill.DeclaredTriggers { + for _, t := range triggers { + skill.Triggers = append(skill.Triggers, strings.ToLower(jsString(t))) + } + } else if jsTruthy(data["name"]) { + skill.Triggers = append(skill.Triggers, strings.ToLower(jsString(data["name"]))) + } + + if jsTruthy(data["prompt"]) { + p := jsString(data["prompt"]) + skill.Prompt = &p + } + + // The capability in the skill graph a workforce definition governs: + // `skill:`, or the id with a `-training` suffix dropped and dashes swapped + // for underscores. + if kind == "workforce" { + base := id + if jsTruthy(data["skill"]) { + base = jsString(data["skill"]) + } else { + base = strings.TrimSuffix(base, "-training") + } + s := strings.ReplaceAll(base, "-", "_") + skill.SkillID = &s + } + + skill.Deferred = deferredBlocks(data) + return skill, nil +} + +// sectionLevels reads the ladder a workforce definition defines, in order, from +// the body's own headings. A rung with no prose is not a rung. +func sectionLevels(body string) []Level { + out := []Level{} + for _, heading := range levelHeadings { + summary := SectionText(body, heading) + if summary == "" { + continue + } + out = append(out, Level{ + Level: strings.ToLower(heading), + Label: heading, + Summary: summary, + }) + } + return out +} + +// deferredBlocks names the frontmatter this package does not semantically +// check. See the package documentation for why they are deferred rather than +// validated or rejected. +func deferredBlocks(data map[string]any) []string { + out := []string{} + for _, key := range []string{"ui", "owliver"} { + if _, present := data[key]; present { + out = append(out, key) + } + } + if len(out) == 0 { + return nil + } + return out +} + +// stringsOf renders a parsed sequence as the strings the frontend would read +// out of it. Non-string entries are stringified rather than dropped, because +// that is what every consumer of `pages` and `actions` does with them. +func stringsOf(list []any) []string { + out := make([]string, 0, len(list)) + for _, v := range list { + out = append(out, jsString(v)) + } + return out +} + +func fileStem(path string) string { + if path == "" { + return "" + } + if i := strings.LastIndexByte(path, '/'); i >= 0 { + path = path[i+1:] + } + return strings.TrimSuffix(path, ".md") +} + +// ValidateSkill decides whether a skill definition may be stored. +// +// Returns nil when it may. The order is the order an author would fix things +// in, and every message below is the frontend's message character for +// character — an author who sees one in the editor and a different one from +// the API is being told about two different problems. +// +// Two rules are the backend's own and are marked as such: the size bound and +// the deferred-block rule. Both are explained in +// docs/phase-4d-parser-contract.md. +func ValidateSkill(raw string) error { + if jsTrim(raw) == "" { + return &Rejection{Message: "Paste or upload a Markdown definition."} + } + + // The backend's own rule, from migration 000005's markdown_size CHECK. The + // editor does not enforce it, so a definition over the bound is one the + // frontend accepts and the DATABASE refuses; refusing it here turns a + // constraint violation into a message. See the contract document. + if n := len([]rune(raw)); n > MaxMarkdownLength { + return &Rejection{ + BackendOnly: true, + Message: fmt.Sprintf( + "That definition is %d characters. The limit is %d.", n, MaxMarkdownLength), + } + } + + skill, err := ParseSkill(raw, Options{}) + if err != nil { + // The subset reports the line it failed on, which is far more useful + // than "could not be parsed". + return &Rejection{Message: jsTrim("That definition could not be parsed. " + err.Error())} + } + + if skill.ID == "" { + return &Rejection{Message: "The frontmatter needs an `id`."} + } + if !isDefinitionID(skill.ID) { + return &Rejection{Message: "`id` must be lower-case letters, numbers and dashes."} + } + // Faithful to the frontend, where `name` has already fallen back to + // `Untitled skill` and this check can therefore never fire. Kept so the + // two validators have the same shape and the same order. + if skill.Name == "" { + return &Rejection{Message: "The frontmatter needs a `name`."} + } + // The backend's own rule, and it must be asked BEFORE the generic one + // below. A `ui:` block declares the pages it draws on, and parseSkill falls + // back to those pages when `pages:` is absent — a fallback this package + // cannot compute, because it does not read the `ui:` vocabulary. Rather + // than report an empty page list it never really established, say what is + // actually missing. No shipped definition relies on the fallback: all + // nineteen that carry a `ui:` or `owliver:` block also declare `pages:`. + if len(skill.Pages) == 0 && len(skill.Deferred) > 0 { + return &Rejection{ + BackendOnly: true, + Message: "A definition with a `ui:` block needs an explicit `pages:` list.", + } + } + if len(skill.Pages) == 0 { + return &Rejection{Message: "The frontmatter needs at least one `pages` entry."} + } + + unknown := []string{} + for _, p := range skill.Pages { + if !SurfaceExists(p) { + unknown = append(unknown, p) + } + } + if len(unknown) > 0 { + plural := "" + if len(unknown) > 1 { + plural = "s" + } + return &Rejection{Message: fmt.Sprintf( + "Unsupported page%s: %s. Supported pages: %s.", + plural, strings.Join(unknown, ", "), strings.Join(SupportedPages, ", "))} + } + + return nil +} diff --git a/go-api/internal/definition/testdata/oracle.json b/go-api/internal/definition/testdata/oracle.json new file mode 100644 index 0000000..878f9f5 --- /dev/null +++ b/go-api/internal/definition/testdata/oracle.json @@ -0,0 +1,9633 @@ +{ + "generatedBy": "scripts/oracle.mjs against the frontend module graph", + "frontendParser": { + "yaml": "src/lib/skills/yaml.js (hand-written YAML subset, no dependency)", + "frontmatter": "src/lib/skills/registry.js — normalizeDefinition / hasFrontmatter / parseFrontmatter", + "skill": "src/lib/skills/registry.js — parseSkill / validateSkillSource", + "agent": "src/lib/agents/registry.js — parseAgent / validateAgentSource" + }, + "vocabulary": { + "pages": [ + { + "id": "control-center", + "aliases": [] + }, + { + "id": "positions", + "aliases": [] + }, + { + "id": "create-position", + "aliases": [ + "new-position" + ] + }, + { + "id": "candidates", + "aliases": [] + }, + { + "id": "hired-history", + "aliases": [ + "hired" + ] + }, + { + "id": "talent-pool", + "aliases": [] + }, + { + "id": "krow-forge", + "aliases": [ + "university", + "forge" + ] + }, + { + "id": "analytics", + "aliases": [] + }, + { + "id": "activity", + "aliases": [] + }, + { + "id": "workspace-agent-configure", + "aliases": [] + }, + { + "id": "settings", + "aliases": [] + }, + { + "id": "workspace", + "aliases": [] + }, + { + "id": "workspace-agents", + "aliases": [] + }, + { + "id": "workspace-skills", + "aliases": [] + }, + { + "id": "workspace-skill-configure", + "aliases": [] + }, + { + "id": "skill-development", + "aliases": [] + }, + { + "id": "profile", + "aliases": [] + }, + { + "id": "candidates-analysis", + "aliases": [] + } + ], + "agentStatuses": [ + "draft", + "published", + "archived" + ], + "defaultAgentStatus": "draft", + "reasoning": [ + "fast", + "balanced", + "deep" + ], + "defaultReasoning": "balanced", + "icons": [ + "owliver", + "sparkles", + "briefcase", + "users", + "user-check", + "layers", + "graduation-cap", + "bar-chart", + "activity", + "shield" + ], + "defaultIcon": "owliver", + "knowledgeKinds": [ + "note", + "link", + "skill-reference" + ], + "defaultKnowledgeKind": "note", + "access": [ + "all", + "specific" + ], + "defaultAccess": "all", + "roles": [ + "manager", + "editor", + "viewer" + ], + "defaultRole": "viewer" + }, + "corpus": [ + { + "path": "src/agents/activity-agent.md", + "type": "agent", + "rawBase64": "LS0tCmlkOiBhY3Rpdml0eS1hZ2VudApuYW1lOiBBY3Rpdml0eSBBZ2VudApkZXNjcmlwdGlvbjogVGhlIGF1ZGl0IHRyYWlsIOKAlCB3aGF0IGhhcHBlbmVkIGluIHRoaXMgd29ya3NwYWNlLCB3aG8gZGlkIGl0LCBhbmQgd2hhdCBsb29rcyB1bnVzdWFsLgppY29uOiBhY3Rpdml0eQpzdGF0dXM6IHB1Ymxpc2hlZAp2ZXJzaW9uOiAxCnJlYXNvbmluZzogYmFsYW5jZWQKdHJpZ2dlcjogVXNlIG9uIEFjdGl2aXR5LCBmb3IgdGhlIGV2ZW50IGxvZywgd2hvIGRpZCB3aGF0LCBhbmQgYW55dGhpbmcgdGhhdCBsb29rcyBvdXQgb2YgcGF0dGVybi4KcGFnZXM6CiAgLSBhY3Rpdml0eQpza2lsbHM6CiAgLSBhY3Rpdml0eS1hbmFseXNpcwogIC0gYW5vbWFseS1kZXRlY3Rpb24KICAtIG9wZXJhdGlvbmFsLXJpc2sKc3RhcnRlcnM6CiAgLSBsYWJlbDogV2hhdCBoYXBwZW5lZCByZWNlbnRseT8KICAgIHByb21wdDogV2hhdCBoYXMgaGFwcGVuZWQgaW4gdGhlIHdvcmtzcGFjZSByZWNlbnRseT8KICAtIGxhYmVsOiBBbnl0aGluZyB1bnVzdWFsPwogICAgcHJvbXB0OiBJcyB0aGVyZSBhbnkgdW51c3VhbCBhY3Rpdml0eT8KcGVybWlzc2lvbnM6CiAgb3duZXI6IGRlbW9Aa3Jvdy5hcHAKICBhY2Nlc3M6IGFsbAotLS0KCiMgQWN0aXZpdHkgQWdlbnQKCiMjIEluc3RydWN0aW9ucwoKQW5zd2VyIGFib3V0IHdoYXQgaGFzIGhhcHBlbmVkIGluIHRoaXMgd29ya3NwYWNlOiB3aGljaCBldmVudHMsIGJ5IHdoaWNoCmFjY291bnQsIGFuZCB3aGVuLgoKUmVwb3J0IHNvbWV0aGluZyBhcyB1bnVzdWFsIG9ubHkgd2hlbiBpdCBnZW51aW5lbHkgZGVwYXJ0cyBmcm9tIHRoZSBwYXR0ZXJuIGluCnRoZSBsb2cuIEZsYWdnaW5nIG9yZGluYXJ5IGFjdGl2aXR5IHRyYWlucyB0aGUgcmVhZGVyIHRvIGlnbm9yZSB0aGUgZmxhZy4KClRoaXMgYWdlbnQgY2FycmllcyBubyBza2lsbHMgb2YgaXRzIG93bjsgQWN0aXZpdHkgYW5zd2VycyBmcm9tIGl0cyBvd24gcGFnZQpyZWFkZXIuCgojIyBQdXJwb3NlCgotIFJlcG9ydCByZWNlbnQgd29ya3NwYWNlIGV2ZW50cyBhbmQgd2hvIHBlcmZvcm1lZCB0aGVtLgotIFN1cmZhY2UgYWN0aXZpdHkgdGhhdCBkZXBhcnRzIGZyb20gdGhlIHVzdWFsIHBhdHRlcm4uCg==", + "bytes": 1123, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "activity-agent", + "name": "Activity Agent", + "description": "The audit trail — what happened in this workspace, who did it, and what looks unusual.", + "icon": "activity", + "status": "published", + "version": 1, + "reasoning": "balanced", + "trigger": "Use on Activity, for the event log, who did what, and anything that looks out of pattern.", + "pages": [ + "activity" + ], + "skills": [ + "activity-analysis", + "anomaly-detection", + "operational-risk" + ], + "starters": [ + { + "label": "What happened recently?", + "prompt": "What has happened in the workspace recently?" + }, + { + "label": "Anything unusual?", + "prompt": "Is there any unusual activity?" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all" + } + }, + "body": "# Activity Agent\n\n## Instructions\n\nAnswer about what has happened in this workspace: which events, by which\naccount, and when.\n\nReport something as unusual only when it genuinely departs from the pattern in\nthe log. Flagging ordinary activity trains the reader to ignore the flag.\n\nThis agent carries no skills of its own; Activity answers from its own page\nreader.\n\n## Purpose\n\n- Report recent workspace events and who performed them.\n- Surface activity that departs from the usual pattern." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "activity-agent", + "name": "Activity Agent", + "description": "The audit trail — what happened in this workspace, who did it, and what looks unusual.", + "status": "published", + "version": 1, + "pages": [ + "activity" + ], + "icon": "activity", + "reasoning": "balanced", + "trigger": "Use on Activity, for the event log, who did what, and anything that looks out of pattern.", + "webSearch": false, + "skills": [ + "activity-analysis", + "anomaly-detection", + "operational-risk" + ], + "subagents": [], + "starters": [ + { + "label": "What happened recently?", + "prompt": "What has happened in the workspace recently?" + }, + { + "label": "Anything unusual?", + "prompt": "Is there any unusual activity?" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/agents/analytics-agent.md", + "type": "agent", + "rawBase64": "LS0tCmlkOiBhbmFseXRpY3MtYWdlbnQKbmFtZTogQW5hbHl0aWNzIEFnZW50CmRlc2NyaXB0aW9uOiBIaXJpbmcgcGVyZm9ybWFuY2Ugb3ZlciB0aW1lIOKAlCB0cmVuZHMsIGNvbnZlcnNpb24sIGFuZCBob3cgZGVwYXJ0bWVudHMgY29tcGFyZS4KaWNvbjogYmFyLWNoYXJ0CnN0YXR1czogcHVibGlzaGVkCnZlcnNpb246IDEKcmVhc29uaW5nOiBiYWxhbmNlZAp0cmlnZ2VyOiBVc2Ugb24gQW5hbHl0aWNzLCBmb3IgdHJlbmRzIG92ZXIgdGltZSwgY29udmVyc2lvbiByYXRlcyBhbmQgZGVwYXJ0bWVudCBjb21wYXJpc29ucy4KcGFnZXM6CiAgLSBhbmFseXRpY3MKc2tpbGxzOgogIC0gYW5hbHl0aWNzLWluc2lnaHRzCiAgLSB3b3JrZm9yY2UtYW5hbHl0aWNzCiAgLSBhdHRlbmRhbmNlLWFuYWx5c2lzCiAgLSBvdmVydGltZS1hbmFseXNpcwogIC0gaGlyaW5nLXB1bHNlLWFuYWx5c2lzCnN0YXJ0ZXJzOgogIC0gbGFiZWw6IFdoYXQgaXMgdGhlIGhpcmluZyB0cmVuZD8KICAgIHByb21wdDogV2hhdCBpcyB0aGUgaGlyaW5nIHRyZW5kPwogIC0gbGFiZWw6IFdoZXJlIGRvZXMgdGhlIGZ1bm5lbCBsb3NlIHBlb3BsZT8KICAgIHByb21wdDogV2hlcmUgZG9lcyB0aGUgZnVubmVsIGxvc2UgY2FuZGlkYXRlcz8KcGVybWlzc2lvbnM6CiAgb3duZXI6IGRlbW9Aa3Jvdy5hcHAKICBhY2Nlc3M6IGFsbAotLS0KCiMgQW5hbHl0aWNzIEFnZW50CgojIyBJbnN0cnVjdGlvbnMKCkFuc3dlciBhYm91dCBwZXJmb3JtYW5jZSBvdmVyIHRpbWU6IGhvdyBoaXJpbmcgaXMgdHJlbmRpbmcsIHdoZXJlIHRoZSBmdW5uZWwKY29udmVydHMgYW5kIHdoZXJlIGl0IGxlYWtzLCBhbmQgaG93IGRlcGFydG1lbnRzIGNvbXBhcmUuCgpFeHBsYWluIHRoZSBmaWd1cmVzIHRoZSBBbmFseXRpY3MgcGFnZSBpcyBhbHJlYWR5IHNob3dpbmcgcmF0aGVyIHRoYW4gcHJvZHVjaW5nCmRpZmZlcmVudCBvbmVzLiBXaGVuIGEgbW92ZW1lbnQgaXMgc21hbGwgZW5vdWdoIHRvIGJlIG5vaXNlLCBzYXkgc28gcmF0aGVyIHRoYW4KbmFycmF0aW5nIGl0IGFzIGEgdHJlbmQuCgojIyBQdXJwb3NlCgotIEV4cGxhaW4gaGlyaW5nIHRyZW5kIGFuZCBjb252ZXJzaW9uLgotIENvbXBhcmUgZGVwYXJ0bWVudCBwZXJmb3JtYW5jZSwgYW5kIGlkZW50aWZ5IHdoZXJlIHRoZSBmdW5uZWwgbG9zZXMgcGVvcGxlLgo=", + "bytes": 1172, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "analytics-agent", + "name": "Analytics Agent", + "description": "Hiring performance over time — trends, conversion, and how departments compare.", + "icon": "bar-chart", + "status": "published", + "version": 1, + "reasoning": "balanced", + "trigger": "Use on Analytics, for trends over time, conversion rates and department comparisons.", + "pages": [ + "analytics" + ], + "skills": [ + "analytics-insights", + "workforce-analytics", + "attendance-analysis", + "overtime-analysis", + "hiring-pulse-analysis" + ], + "starters": [ + { + "label": "What is the hiring trend?", + "prompt": "What is the hiring trend?" + }, + { + "label": "Where does the funnel lose people?", + "prompt": "Where does the funnel lose candidates?" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all" + } + }, + "body": "# Analytics Agent\n\n## Instructions\n\nAnswer about performance over time: how hiring is trending, where the funnel\nconverts and where it leaks, and how departments compare.\n\nExplain the figures the Analytics page is already showing rather than producing\ndifferent ones. When a movement is small enough to be noise, say so rather than\nnarrating it as a trend.\n\n## Purpose\n\n- Explain hiring trend and conversion.\n- Compare department performance, and identify where the funnel loses people." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "analytics-agent", + "name": "Analytics Agent", + "description": "Hiring performance over time — trends, conversion, and how departments compare.", + "status": "published", + "version": 1, + "pages": [ + "analytics" + ], + "icon": "bar-chart", + "reasoning": "balanced", + "trigger": "Use on Analytics, for trends over time, conversion rates and department comparisons.", + "webSearch": false, + "skills": [ + "analytics-insights", + "workforce-analytics", + "attendance-analysis", + "overtime-analysis", + "hiring-pulse-analysis" + ], + "subagents": [], + "starters": [ + { + "label": "What is the hiring trend?", + "prompt": "What is the hiring trend?" + }, + { + "label": "Where does the funnel lose people?", + "prompt": "Where does the funnel lose candidates?" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/agents/candidates-agent.md", + "type": "agent", + "rawBase64": "LS0tCmlkOiBjYW5kaWRhdGVzLWFnZW50Cm5hbWU6IENhbmRpZGF0ZXMgQWdlbnQKZGVzY3JpcHRpb246IFRoZSBhcHBsaWNhbnQgcG9vbCDigJQgd2hvIGlzIHdhaXRpbmcgb24gYSBkZWNpc2lvbiwgd2hvIGlzIHN0cm9uZ2VzdCwgYW5kIHdoZXJlIHBlb3BsZSBhcmUgZHJvcHBpbmcgb2ZmLgppY29uOiB1c2VycwpzdGF0dXM6IHB1Ymxpc2hlZAp2ZXJzaW9uOiAxCnJlYXNvbmluZzogYmFsYW5jZWQKdHJpZ2dlcjogVXNlIG9uIENhbmRpZGF0ZXMsIGZvciBzY3JlZW5pbmcsIHNob3J0bGlzdGluZyBhbmQgcGlwZWxpbmUgcXVlc3Rpb25zIGFib3V0IGFwcGxpY2FudHMuCnBhZ2VzOgogIC0gY2FuZGlkYXRlcwogIC0gY2FuZGlkYXRlcy1hbmFseXNpcwpza2lsbHM6CiAgLSBjYW5kaWRhdGUtc2VhcmNoCiAgLSBjYW5kaWRhdGUtYW5hbHlzaXMKc3RhcnRlcnM6CiAgLSBsYWJlbDogV2hvIG5lZWRzIGEgZGVjaXNpb24/CiAgICBwcm9tcHQ6IFdoaWNoIGNhbmRpZGF0ZXMgYXJlIHdhaXRpbmcgb24gYSBkZWNpc2lvbj8KICAtIGxhYmVsOiBXaG8gaXMgc3Ryb25nZXN0PwogICAgcHJvbXB0OiBXaG8gYXJlIHRoZSBzdHJvbmdlc3QgY2FuZGlkYXRlcyByaWdodCBub3c/CnBlcm1pc3Npb25zOgogIG93bmVyOiBkZW1vQGtyb3cuYXBwCiAgYWNjZXNzOiBhbGwKLS0tCgojIENhbmRpZGF0ZXMgQWdlbnQKCiMjIEluc3RydWN0aW9ucwoKQW5zd2VyIGFib3V0IHRoZSBwZW9wbGUgd2hvIGhhdmUgYXBwbGllZDogd2hvIGlzIHdhaXRpbmcsIHdobyBzY29yZXMgd2VsbCwgd2hvCmhhcyBub3QgYmVlbiBzY3JlZW5lZCwgYW5kIHdoZXJlIHRoZSBwaXBlbGluZSBpcyBsb3NpbmcgY2FuZGlkYXRlcy4KClF1b3RlIGEgc2NvcmUgb25seSB3aGVyZSBvbmUgaGFzIGJlZW4gY29tcHV0ZWQuIEFuIHVuc2NvcmVkIGNhbmRpZGF0ZSBpcwp1bnNjb3JlZCDigJQgc2F5IHNvIHJhdGhlciB0aGFuIGltcGx5aW5nIGEgbG93IHNjb3JlLgoKTmV2ZXIgYWR2YW5jZSwgZGVjbGluZSBvciBoaXJlIGEgY2FuZGlkYXRlIHdpdGhvdXQgYmVpbmcgYXNrZWQgdG8uCgojIyBQdXJwb3NlCgotIFJlcG9ydCB3aG8gaXMgd2FpdGluZyBvbiBhIGRlY2lzaW9uLCBhbmQgd2hvIGlzIHN0cm9uZ2VzdC4KLSBGaW5kIGNhbmRpZGF0ZXMgbWF0Y2hpbmcgd2hhdCBhIHJvbGUgYXNrcyBmb3IuCg==", + "bytes": 1165, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "candidates-agent", + "name": "Candidates Agent", + "description": "The applicant pool — who is waiting on a decision, who is strongest, and where people are dropping off.", + "icon": "users", + "status": "published", + "version": 1, + "reasoning": "balanced", + "trigger": "Use on Candidates, for screening, shortlisting and pipeline questions about applicants.", + "pages": [ + "candidates", + "candidates-analysis" + ], + "skills": [ + "candidate-search", + "candidate-analysis" + ], + "starters": [ + { + "label": "Who needs a decision?", + "prompt": "Which candidates are waiting on a decision?" + }, + { + "label": "Who is strongest?", + "prompt": "Who are the strongest candidates right now?" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all" + } + }, + "body": "# Candidates Agent\n\n## Instructions\n\nAnswer about the people who have applied: who is waiting, who scores well, who\nhas not been screened, and where the pipeline is losing candidates.\n\nQuote a score only where one has been computed. An unscored candidate is\nunscored — say so rather than implying a low score.\n\nNever advance, decline or hire a candidate without being asked to.\n\n## Purpose\n\n- Report who is waiting on a decision, and who is strongest.\n- Find candidates matching what a role asks for." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "candidates-agent", + "name": "Candidates Agent", + "description": "The applicant pool — who is waiting on a decision, who is strongest, and where people are dropping off.", + "status": "published", + "version": 1, + "pages": [ + "candidates", + "candidates-analysis" + ], + "icon": "users", + "reasoning": "balanced", + "trigger": "Use on Candidates, for screening, shortlisting and pipeline questions about applicants.", + "webSearch": false, + "skills": [ + "candidate-search", + "candidate-analysis" + ], + "subagents": [], + "starters": [ + { + "label": "Who needs a decision?", + "prompt": "Which candidates are waiting on a decision?" + }, + { + "label": "Who is strongest?", + "prompt": "Who are the strongest candidates right now?" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/agents/control-center-agent.md", + "type": "agent", + "rawBase64": "LS0tCmlkOiBjb250cm9sLWNlbnRlci1hZ2VudApuYW1lOiBDb250cm9sIENlbnRlciBBZ2VudApkZXNjcmlwdGlvbjogVGhlIG9wZXJhdGlvbmFsIHBpY3R1cmUg4oCUIHdoYXQgbmVlZHMgYXR0ZW50aW9uIGFjcm9zcyB0aGUgd29ya3NwYWNlIHRvZGF5LgppY29uOiBsYXllcnMKc3RhdHVzOiBwdWJsaXNoZWQKdmVyc2lvbjogMQpyZWFzb25pbmc6IGJhbGFuY2VkCnRyaWdnZXI6IFVzZSBvbiB0aGUgQ29udHJvbCBDZW50ZXIsIGZvciB3b3Jrc3BhY2UgaGVhbHRoLCB1cmdlbmN5IGFuZCB3aGF0IHRvIGRvIG5leHQuCnBhZ2VzOgogIC0gY29udHJvbC1jZW50ZXIKc2tpbGxzOgogIC0gZXhlY3V0aXZlLXN1bW1hcnkKICAtIHN0YWZmaW5nLXJpc2sKICAtIG9wZXJhdGlvbmFsLXJpc2sKICAtIGFub21hbHktZGV0ZWN0aW9uCiAgLSBhdHRlbmRhbmNlLWFuYWx5c2lzCiAgLSBvdmVydGltZS1hbmFseXNpcwogIC0gaGlyaW5nLXB1bHNlLWFuYWx5c2lzCnN0YXJ0ZXJzOgogIC0gbGFiZWw6IFdoYXQgbmVlZHMgbXkgYXR0ZW50aW9uPwogICAgcHJvbXB0OiBXaGF0IG5lZWRzIG15IGF0dGVudGlvbiByaWdodCBub3c/CiAgLSBsYWJlbDogSG93IGlzIHRoZSBwaXBlbGluZT8KICAgIHByb21wdDogSG93IGhlYWx0aHkgaXMgbXkgaGlyaW5nIHBpcGVsaW5lPwpwZXJtaXNzaW9uczoKICBvd25lcjogZGVtb0Brcm93LmFwcAogIGFjY2VzczogYWxsCi0tLQoKIyBDb250cm9sIENlbnRlciBBZ2VudAoKIyMgSW5zdHJ1Y3Rpb25zCgpBbnN3ZXIgYWJvdXQgdGhlIHN0YXRlIG9mIHRoZSB3b3Jrc3BhY2UgYXMgYSB3aG9sZTogd2hhdCBpcyB1cmdlbnQsIHdoZXJlIHRoZQpmdW5uZWwgaXMgbG9zaW5nIHBlb3BsZSwgYW5kIHdoYXQgdGhlIHJlYWRlciBzaG91bGQgZG8gbmV4dC4KClJlYWQgdGhlIGZpZ3VyZXMgdGhlIENvbnRyb2wgQ2VudGVyIGFscmVhZHkgc2hvd3MgcmF0aGVyIHRoYW4gcmVjb21wdXRpbmcgdGhlbSwKc28gdGhlIGFuc3dlciBhbmQgdGhlIGRhc2hib2FyZCBiZXNpZGUgaXQgY2FuIG5ldmVyIGRpc2FncmVlLgoKVGhpcyBhZ2VudCBjYXJyaWVzIG5vIHNraWxscyBvZiBpdHMgb3duLiBUaGF0IGlzIGRlbGliZXJhdGUg4oCUIHRoZSBDb250cm9sCkNlbnRlciBhbnN3ZXJzIGZyb20gaXRzIG93biBwYWdlIHJlYWRlciwgYW5kIGludmVudGluZyBza2lsbHMgdG8gZmlsbCB0aGUgbGlzdAp3b3VsZCBwcm9taXNlIGNhcGFiaWxpdGllcyB0aGF0IGRvIG5vdCBleGlzdC4KCiMjIFB1cnBvc2UKCi0gU2F5IHdoYXQgbmVlZHMgYXR0ZW50aW9uIGFjcm9zcyB0aGUgd29ya3NwYWNlLgotIEV4cGxhaW4gd2hlcmUgdGhlIGhpcmluZyBmdW5uZWwgaXMgbG9zaW5nIGNhbmRpZGF0ZXMuCg==", + "bytes": 1354, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "control-center-agent", + "name": "Control Center Agent", + "description": "The operational picture — what needs attention across the workspace today.", + "icon": "layers", + "status": "published", + "version": 1, + "reasoning": "balanced", + "trigger": "Use on the Control Center, for workspace health, urgency and what to do next.", + "pages": [ + "control-center" + ], + "skills": [ + "executive-summary", + "staffing-risk", + "operational-risk", + "anomaly-detection", + "attendance-analysis", + "overtime-analysis", + "hiring-pulse-analysis" + ], + "starters": [ + { + "label": "What needs my attention?", + "prompt": "What needs my attention right now?" + }, + { + "label": "How is the pipeline?", + "prompt": "How healthy is my hiring pipeline?" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all" + } + }, + "body": "# Control Center Agent\n\n## Instructions\n\nAnswer about the state of the workspace as a whole: what is urgent, where the\nfunnel is losing people, and what the reader should do next.\n\nRead the figures the Control Center already shows rather than recomputing them,\nso the answer and the dashboard beside it can never disagree.\n\nThis agent carries no skills of its own. That is deliberate — the Control\nCenter answers from its own page reader, and inventing skills to fill the list\nwould promise capabilities that do not exist.\n\n## Purpose\n\n- Say what needs attention across the workspace.\n- Explain where the hiring funnel is losing candidates." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "control-center-agent", + "name": "Control Center Agent", + "description": "The operational picture — what needs attention across the workspace today.", + "status": "published", + "version": 1, + "pages": [ + "control-center" + ], + "icon": "layers", + "reasoning": "balanced", + "trigger": "Use on the Control Center, for workspace health, urgency and what to do next.", + "webSearch": false, + "skills": [ + "executive-summary", + "staffing-risk", + "operational-risk", + "anomaly-detection", + "attendance-analysis", + "overtime-analysis", + "hiring-pulse-analysis" + ], + "subagents": [], + "starters": [ + { + "label": "What needs my attention?", + "prompt": "What needs my attention right now?" + }, + { + "label": "How is the pipeline?", + "prompt": "How healthy is my hiring pipeline?" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/agents/hired-history-agent.md", + "type": "agent", + "rawBase64": "LS0tCmlkOiBoaXJlZC1oaXN0b3J5LWFnZW50Cm5hbWU6IEhpcmVkIEhpc3RvcnkgQWdlbnQKZGVzY3JpcHRpb246IENvbXBsZXRlZCBoaXJlcyDigJQgd2hvIHdhcyBoaXJlZCwgZm9yIHdoaWNoIHJvbGUsIGhvdyBxdWlja2x5LCBhbmQgaG93IHdlbGwuCmljb246IHVzZXItY2hlY2sKc3RhdHVzOiBwdWJsaXNoZWQKdmVyc2lvbjogMQpyZWFzb25pbmc6IGJhbGFuY2VkCnRyaWdnZXI6IFVzZSBvbiBIaXJlZCBIaXN0b3J5LCBmb3IgaGlyaW5nIG91dGNvbWVzLCB0aW1lLXRvLWhpcmUgYW5kIHF1YWxpdHkgYnkgZGVwYXJ0bWVudC4KcGFnZXM6CiAgLSBoaXJlZC1oaXN0b3J5CnNraWxsczoKICAtIGhpcmluZy1oaXN0b3J5LWFuYWx5c2lzCnN0YXJ0ZXJzOgogIC0gbGFiZWw6IFdobyBkaWQgd2UgaGlyZSByZWNlbnRseT8KICAgIHByb21wdDogV2hvIGRpZCB3ZSBoaXJlIHJlY2VudGx5PwogIC0gbGFiZWw6IEhvdyBpcyBoaXJlIHF1YWxpdHk/CiAgICBwcm9tcHQ6IEhvdyBpcyBoaXJlIHF1YWxpdHkgYnkgZGVwYXJ0bWVudD8KcGVybWlzc2lvbnM6CiAgb3duZXI6IGRlbW9Aa3Jvdy5hcHAKICBhY2Nlc3M6IGFsbAotLS0KCiMgSGlyZWQgSGlzdG9yeSBBZ2VudAoKIyMgSW5zdHJ1Y3Rpb25zCgpBbnN3ZXIgYWJvdXQgaGlyZXMgdGhhdCBoYXZlIGFscmVhZHkgaGFwcGVuZWQ6IHdobywgZm9yIHdoaWNoIHJvbGUsIGhvdyBsb25nIGl0CnRvb2sgYW5kIGhvdyB0aGV5IHNjb3JlZC4KClRoaXMgaXMgdGhlIHJlY29yZCBhZnRlciB0aGUgZGVjaXNpb24sIG5vdCB0aGUgcGlwZWxpbmUgYmVmb3JlIGl0LiBBIHF1ZXN0aW9uCmFib3V0IHBlb3BsZSBzdGlsbCBiZWluZyBjb25zaWRlcmVkIGJlbG9uZ3MgdG8gQ2FuZGlkYXRlcy4KClRoaXMgYWdlbnQgY2FycmllcyBubyBza2lsbHMgb2YgaXRzIG93bi4gSGlyZWQgSGlzdG9yeSBhbnN3ZXJzIGZyb20gaXRzIG93bgpwYWdlIHJlYWRlciwgYW5kIGEgcGxhY2Vob2xkZXIgc2tpbGwgd291bGQgcHJvbWlzZSBhIGNhcGFiaWxpdHkgdGhhdCBkb2VzIG5vdApleGlzdC4KCiMjIFB1cnBvc2UKCi0gUmVwb3J0IHJlY2VudCBoaXJlcywgYW5kIGhvdyBxdWlja2x5IHRoZXkgd2VyZSBtYWRlLgotIENvbXBhcmUgaGlyaW5nIG91dGNvbWVzIGFjcm9zcyBkZXBhcnRtZW50cy4K", + "bytes": 1143, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "hired-history-agent", + "name": "Hired History Agent", + "description": "Completed hires — who was hired, for which role, how quickly, and how well.", + "icon": "user-check", + "status": "published", + "version": 1, + "reasoning": "balanced", + "trigger": "Use on Hired History, for hiring outcomes, time-to-hire and quality by department.", + "pages": [ + "hired-history" + ], + "skills": [ + "hiring-history-analysis" + ], + "starters": [ + { + "label": "Who did we hire recently?", + "prompt": "Who did we hire recently?" + }, + { + "label": "How is hire quality?", + "prompt": "How is hire quality by department?" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all" + } + }, + "body": "# Hired History Agent\n\n## Instructions\n\nAnswer about hires that have already happened: who, for which role, how long it\ntook and how they scored.\n\nThis is the record after the decision, not the pipeline before it. A question\nabout people still being considered belongs to Candidates.\n\nThis agent carries no skills of its own. Hired History answers from its own\npage reader, and a placeholder skill would promise a capability that does not\nexist.\n\n## Purpose\n\n- Report recent hires, and how quickly they were made.\n- Compare hiring outcomes across departments." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "hired-history-agent", + "name": "Hired History Agent", + "description": "Completed hires — who was hired, for which role, how quickly, and how well.", + "status": "published", + "version": 1, + "pages": [ + "hired-history" + ], + "icon": "user-check", + "reasoning": "balanced", + "trigger": "Use on Hired History, for hiring outcomes, time-to-hire and quality by department.", + "webSearch": false, + "skills": [ + "hiring-history-analysis" + ], + "subagents": [], + "starters": [ + { + "label": "Who did we hire recently?", + "prompt": "Who did we hire recently?" + }, + { + "label": "How is hire quality?", + "prompt": "How is hire quality by department?" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/agents/krow-forge-agent.md", + "type": "agent", + "rawBase64": "LS0tCmlkOiBrcm93LWZvcmdlLWFnZW50Cm5hbWU6IEtST1cgRm9yZ2UgQWdlbnQKZGVzY3JpcHRpb246IFRoZSB0cmFpbmluZyBsaWJyYXJ5IOKAlCB3aGF0IGV4aXN0cywgd2hhdCBpcyBwdWJsaXNoZWQsIGFuZCBob3cgdGhlIHdvcmtmb3JjZSBpcyBwcm9ncmVzc2luZy4KaWNvbjogZ3JhZHVhdGlvbi1jYXAKc3RhdHVzOiBwdWJsaXNoZWQKdmVyc2lvbjogMQpyZWFzb25pbmc6IGJhbGFuY2VkCnRyaWdnZXI6IFVzZSBvbiBLUk9XIEZvcmdlLCBmb3IgdHJhaW5pbmcgcGF0aHMsIGNoYWxsZW5nZXMsIHZlcmlmaWNhdGlvbiBhbmQgc2tpbGwgcHJvZ3Jlc3Npb24uCnBhZ2VzOgogIC0ga3Jvdy1mb3JnZQpza2lsbHM6CiAgLSBmb3JnZS1za2lsbC1tYW5hZ2VtZW50CiAgLSBsZWFybmluZy1hbmFseXNpcwpzdGFydGVyczoKICAtIGxhYmVsOiBXaGF0IGlzIGluIHRoZSBsaWJyYXJ5PwogICAgcHJvbXB0OiBXaGF0IHRyYWluaW5nIGRvZXMgdGhlIGxpYnJhcnkgaG9sZD8KICAtIGxhYmVsOiBXaGVyZSBhcmUgdGhlIGdhcHM/CiAgICBwcm9tcHQ6IFdoZXJlIGFyZSB0aGUgZ2FwcyBpbiB3b3JrZm9yY2UgdHJhaW5pbmc/CnBlcm1pc3Npb25zOgogIG93bmVyOiBkZW1vQGtyb3cuYXBwCiAgYWNjZXNzOiBhbGwKLS0tCgojIEtST1cgRm9yZ2UgQWdlbnQKCiMjIEluc3RydWN0aW9ucwoKQW5zd2VyIGFib3V0IHRoZSB0cmFpbmluZyBsaWJyYXJ5IGFuZCB3aGF0IHRoZSB3b3JrZm9yY2UgaGFzIHByb3ZlZDogd2hpY2gKcGF0aHMgZXhpc3QsIHdoaWNoIGFyZSBwdWJsaXNoZWQsIHdoYXQgYSBjaGFsbGVuZ2UgY2hlY2tzLCBhbmQgd2hlcmUgY292ZXJhZ2UKaXMgdGhpbi4KCkEgc2tpbGwgaW4gRm9yZ2UgaXMgc29tZXRoaW5nIGEgcGVyc29uIGxlYXJucyBhbmQgaXMgdmVyaWZpZWQgaW4uIEl0IGlzIG5vdCBhbgpPd2xpdmVyIGNhcGFiaWxpdHkg4oCUIG5ldmVyIGRlc2NyaWJlIHRoZSB0d28gYXMgdGhlIHNhbWUgdGhpbmcuCgpOZXZlciBwdWJsaXNoIG9yIGFyY2hpdmUgdHJhaW5pbmcgd2l0aG91dCBiZWluZyBhc2tlZCB0by4KCiMjIFB1cnBvc2UKCi0gUmVwb3J0IHdoYXQgdGhlIHRyYWluaW5nIGxpYnJhcnkgaG9sZHMgYW5kIHdoYXQgaXMgbGl2ZS4KLSBJZGVudGlmeSBnYXBzIGJldHdlZW4gd2hhdCByb2xlcyBuZWVkIGFuZCB3aGF0IGlzIHRhdWdodC4K", + "bytes": 1170, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "krow-forge-agent", + "name": "KROW Forge Agent", + "description": "The training library — what exists, what is published, and how the workforce is progressing.", + "icon": "graduation-cap", + "status": "published", + "version": 1, + "reasoning": "balanced", + "trigger": "Use on KROW Forge, for training paths, challenges, verification and skill progression.", + "pages": [ + "krow-forge" + ], + "skills": [ + "forge-skill-management", + "learning-analysis" + ], + "starters": [ + { + "label": "What is in the library?", + "prompt": "What training does the library hold?" + }, + { + "label": "Where are the gaps?", + "prompt": "Where are the gaps in workforce training?" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all" + } + }, + "body": "# KROW Forge Agent\n\n## Instructions\n\nAnswer about the training library and what the workforce has proved: which\npaths exist, which are published, what a challenge checks, and where coverage\nis thin.\n\nA skill in Forge is something a person learns and is verified in. It is not an\nOwliver capability — never describe the two as the same thing.\n\nNever publish or archive training without being asked to.\n\n## Purpose\n\n- Report what the training library holds and what is live.\n- Identify gaps between what roles need and what is taught." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "krow-forge-agent", + "name": "KROW Forge Agent", + "description": "The training library — what exists, what is published, and how the workforce is progressing.", + "status": "published", + "version": 1, + "pages": [ + "krow-forge" + ], + "icon": "graduation-cap", + "reasoning": "balanced", + "trigger": "Use on KROW Forge, for training paths, challenges, verification and skill progression.", + "webSearch": false, + "skills": [ + "forge-skill-management", + "learning-analysis" + ], + "subagents": [], + "starters": [ + { + "label": "What is in the library?", + "prompt": "What training does the library hold?" + }, + { + "label": "Where are the gaps?", + "prompt": "Where are the gaps in workforce training?" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/agents/krow-workforce-agent.md", + "type": "agent", + "rawBase64": "LS0tCmlkOiBrcm93LXdvcmtmb3JjZS1hZ2VudApuYW1lOiBLcm93IFdvcmtmb3JjZSBBZ2VudApkZXNjcmlwdGlvbjogVGhlIGdlbmVyYWwgd29ya2ZvcmNlIGFnZW50LiBSZWFzb25zIGFjcm9zcyBldmVyeSBLcm93IGRvbWFpbiwgd2l0aGluIHdoYXRldmVyIHBhZ2UgeW91IGFyZSBvbi4KaWNvbjogb3dsaXZlcgpzdGF0dXM6IHB1Ymxpc2hlZAp2ZXJzaW9uOiAxCnJlYXNvbmluZzogYmFsYW5jZWQKdHJpZ2dlcjogVXNlIHdoZW4gYSBxdWVzdGlvbiBzcGFucyBtb3JlIHRoYW4gb25lIEtyb3cgZG9tYWluLCBvciB3aGVuIHlvdSBhcmUgb24gYSBwYWdlIHdob3NlIG93biBhZ2VudCBjYW5ub3QgaGVscC4KcGFnZXM6CiAgLSBjb250cm9sLWNlbnRlcgogIC0gcG9zaXRpb25zCiAgLSBjcmVhdGUtcG9zaXRpb24KICAtIGNhbmRpZGF0ZXMKICAtIGNhbmRpZGF0ZXMtYW5hbHlzaXMKICAtIGhpcmVkLWhpc3RvcnkKICAtIHRhbGVudC1wb29sCiAgLSBrcm93LWZvcmdlCiAgLSBhbmFseXRpY3MKICAtIGFjdGl2aXR5CiAgLSBwcm9maWxlCiAgIyBUaGUgYWdlbnQgd29ya3NwYWNlLiBDYXJyaWVzIG5vIG9wZXJhdGlvbmFsIHNraWxsLCBzbyBzdGFuZGluZyBoZXJlIHRoZQogICMgcm9vdCBhZ2VudCBhbnN3ZXJzIGFib3V0IGFnZW50cyBhbmQgc2tpbGxzIGFuZCBub3RoaW5nIGVsc2Ug4oCUIHdoaWNoIGlzIHRoZQogICMgcG9pbnQ6IGNvbmZpZ3VyaW5nIHRoZSBBbmFseXRpY3MgQWdlbnQgbXVzdCBub3QgcHV0IHRoZSByZWFkZXIgb24gQW5hbHl0aWNzLgogIC0gd29ya3NwYWNlLWFnZW50LWNvbmZpZ3VyZQogICMgU2V0dGluZ3MgYW5kIHRoZSByZXN0IG9mIHRoZSB3b3Jrc3BhY2UuIE5vYm9keSB3cm90ZSBhIHNwZWNpYWxpc3QgZm9yIGEKICAjIGNvbmZpZ3VyYXRpb24gc2NyZWVuIGFuZCBub2JvZHkgc2hvdWxkOiB0aGVzZSBwYWdlcyBob2xkIG5vIHdvcmtmb3JjZQogICMgcmVjb3Jkcywgc28gd2hhdCB0aGV5IG5lZWQgaXMgYSBnZW5lcmFsIGFnZW50LCBub3QgYSBTZXR0aW5ncyBBZ2VudCB3aXRoCiAgIyBpbnZlbnRlZCBza2lsbHMuIExpc3RpbmcgdGhlbSBoZXJlIGlzIHRoZSB3aG9sZSBvZiB0aGUgZmFsbGJhY2sg4oCUIGEgcGFnZQogICMgbmFtZWQgYnkgdGhpcyBhZ2VudCBoYXMgYW4gYWdlbnQsIGFuZCBPd2xpdmVyIGlzIGFsaXZlIG9uIGl0LgogIC0gc2V0dGluZ3MKICAtIHdvcmtzcGFjZQogIC0gd29ya3NwYWNlLWFnZW50cwogIC0gd29ya3NwYWNlLXNraWxscwogIC0gd29ya3NwYWNlLXNraWxsLWNvbmZpZ3VyZQogIC0gc2tpbGwtZGV2ZWxvcG1lbnQKc2tpbGxzOgogIC0gY3JlYXRlLXBvc2l0aW9uCiAgLSBoaXJpbmctYWN0aXZpdHktYXNzaXN0YW50CiAgLSBjYW5kaWRhdGUtc2VhcmNoCiAgLSBhbmFseXRpY3MtaW5zaWdodHMKICAtIGZvcmdlLXNraWxsLW1hbmFnZW1lbnQKICAtIHN0YWZmaW5nLXJpc2sKICAtIGF0dGVuZGFuY2UtYW5hbHlzaXMKICAtIG92ZXJ0aW1lLWFuYWx5c2lzCiAgLSBjYW5kaWRhdGUtYW5hbHlzaXMKICAtIHRhbGVudC1wb29sLWFuYWx5c2lzCiAgLSB3b3JrZm9yY2UtYW5hbHl0aWNzCiAgLSBhbm9tYWx5LWRldGVjdGlvbgogIC0gYWN0aXZpdHktYW5hbHlzaXMKICAtIG9wZXJhdGlvbmFsLXJpc2sKICAtIGV4ZWN1dGl2ZS1zdW1tYXJ5CiAgLSBoaXJpbmctaGlzdG9yeS1hbmFseXNpcwogIC0gbGVhcm5pbmctYW5hbHlzaXMKICAtIGhpcmluZy1wdWxzZS1hbmFseXNpcwpzdWJhZ2VudHM6CiAgLSBjb250cm9sLWNlbnRlci1hZ2VudAogIC0gcG9zaXRpb25zLWFnZW50CiAgLSBjYW5kaWRhdGVzLWFnZW50CiAgLSBoaXJlZC1oaXN0b3J5LWFnZW50CiAgLSB0YWxlbnQtcG9vbC1hZ2VudAogIC0ga3Jvdy1mb3JnZS1hZ2VudAogIC0gYW5hbHl0aWNzLWFnZW50CiAgLSBhY3Rpdml0eS1hZ2VudAprbm93bGVkZ2U6CiAgLSBpZDogcGFnZS1ib3VuZGFyeQogICAgbGFiZWw6IFdoYXQgdGhpcyBhZ2VudCBjYW4gc2VlCiAgICBraW5kOiBub3RlCiAgICBib2R5OiBPd2xpdmVyIGFuc3dlcnMgZnJvbSB0aGUgcGFnZSB5b3UgYXJlIG9uLiBDb3ZlcmluZyBldmVyeSBwYWdlIGRvZXMgbm90IG1lYW4gcmVhZGluZyBldmVyeSBwYWdlIGF0IG9uY2Ug4oCUIHRoZSBwYWdlIHlvdSBhcmUgc3RhbmRpbmcgb24gZGVjaWRlcyB3aGljaCByZWNvcmRzIGFyZSBpbiByZWFjaC4Kc3RhcnRlcnM6CiAgLSBsYWJlbDogV2hhdCBuZWVkcyBteSBhdHRlbnRpb24/CiAgICBwcm9tcHQ6IFdoYXQgbmVlZHMgbXkgYXR0ZW50aW9uIHJpZ2h0IG5vdz8KICAtIGxhYmVsOiBTdW1tYXJpemUgdGhpcyBwYWdlCiAgICBwcm9tcHQ6IFN1bW1hcml6ZSB3aGF0IHRoaXMgcGFnZSBpcyBzaG93aW5nCnBlcm1pc3Npb25zOgogIG93bmVyOiBkZW1vQGtyb3cuYXBwCiAgYWNjZXNzOiBhbGwKICBwZW9wbGU6CiAgICAtIHVzZXI6IGRlbW9Aa3Jvdy5hcHAKICAgICAgcm9sZTogbWFuYWdlcgotLS0KCiMgS3JvdyBXb3JrZm9yY2UgQWdlbnQKCiMjIEluc3RydWN0aW9ucwoKQW5zd2VyIGZyb20gdGhlIHJlY29yZHMgdGhpcyB3b3Jrc3BhY2UgaG9sZHMsIGZvciB0aGUgcGFnZSB0aGUgcmVhZGVyIGlzIG9uLgoKU3RhdGUgYSBmaWd1cmUgb25seSB3aGVyZSBhIHNraWxsIGhhcyByZWFkIGl0LiBXaGVuIGEgcmVhZGluZyBuZWVkcyBhIHBvc2l0aW9uCm9yIGEgY2FuZGlkYXRlIGFuZCBub25lIGlzIG9wZW4sIGFzayB3aGljaCBvbmUgcmF0aGVyIHRoYW4gY2hvb3Npbmcgb25lLgoKQ292ZXJpbmcgZXZlcnkgcGFnZSBpcyBub3QgcGVybWlzc2lvbiB0byByZWFkIGV2ZXJ5IHBhZ2UgYXQgb25jZS4gVGhlIHBhZ2UgaW4KZnJvbnQgb2YgdGhlIHJlYWRlciBkZWNpZGVzIHdoYXQgaXMgaW4gcmVhY2g7IGEgcXVlc3Rpb24gdGhhdCBiZWxvbmdzIHNvbWV3aGVyZQplbHNlIHNob3VsZCBiZSBhbnN3ZXJlZCBieSBuYW1pbmcgd2hlcmUgaXQgYmVsb25ncywgbm90IGJ5IHJlYWNoaW5nIGZvciBpdC4KCiMjIFB1cnBvc2UKCi0gQW5zd2VyIHF1ZXN0aW9ucyB0aGF0IHNwYW4gbW9yZSB0aGFuIG9uZSBLcm93IGRvbWFpbi4KLSBTdGFuZCBpbiBvbiBwYWdlcyB3aG9zZSBvd24gYWdlbnQgY2FycmllcyBubyBza2lsbHMuCi0gSGFuZCBhIHF1ZXN0aW9uIHRoYXQgY2xlYXJseSBiZWxvbmdzIHRvIGFub3RoZXIgcGFnZSBiYWNrIHRvIHRoYXQgcGFnZS4K", + "bytes": 3156, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "krow-workforce-agent", + "name": "Krow Workforce Agent", + "description": "The general workforce agent. Reasons across every Krow domain, within whatever page you are on.", + "icon": "owliver", + "status": "published", + "version": 1, + "reasoning": "balanced", + "trigger": "Use when a question spans more than one Krow domain, or when you are on a page whose own agent cannot help.", + "pages": [ + "control-center", + "positions", + "create-position", + "candidates", + "candidates-analysis", + "hired-history", + "talent-pool", + "krow-forge", + "analytics", + "activity", + "profile", + "workspace-agent-configure", + "settings", + "workspace", + "workspace-agents", + "workspace-skills", + "workspace-skill-configure", + "skill-development" + ], + "skills": [ + "create-position", + "hiring-activity-assistant", + "candidate-search", + "analytics-insights", + "forge-skill-management", + "staffing-risk", + "attendance-analysis", + "overtime-analysis", + "candidate-analysis", + "talent-pool-analysis", + "workforce-analytics", + "anomaly-detection", + "activity-analysis", + "operational-risk", + "executive-summary", + "hiring-history-analysis", + "learning-analysis", + "hiring-pulse-analysis" + ], + "subagents": [ + "control-center-agent", + "positions-agent", + "candidates-agent", + "hired-history-agent", + "talent-pool-agent", + "krow-forge-agent", + "analytics-agent", + "activity-agent" + ], + "knowledge": [ + { + "id": "page-boundary", + "label": "What this agent can see", + "kind": "note", + "body": "Owliver answers from the page you are on. Covering every page does not mean reading every page at once — the page you are standing on decides which records are in reach." + } + ], + "starters": [ + { + "label": "What needs my attention?", + "prompt": "What needs my attention right now?" + }, + { + "label": "Summarize this page", + "prompt": "Summarize what this page is showing" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all", + "people": [ + { + "user": "demo@krow.app", + "role": "manager" + } + ] + } + }, + "body": "# Krow Workforce Agent\n\n## Instructions\n\nAnswer from the records this workspace holds, for the page the reader is on.\n\nState a figure only where a skill has read it. When a reading needs a position\nor a candidate and none is open, ask which one rather than choosing one.\n\nCovering every page is not permission to read every page at once. The page in\nfront of the reader decides what is in reach; a question that belongs somewhere\nelse should be answered by naming where it belongs, not by reaching for it.\n\n## Purpose\n\n- Answer questions that span more than one Krow domain.\n- Stand in on pages whose own agent carries no skills.\n- Hand a question that clearly belongs to another page back to that page." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "krow-workforce-agent", + "name": "Krow Workforce Agent", + "description": "The general workforce agent. Reasons across every Krow domain, within whatever page you are on.", + "status": "published", + "version": 1, + "pages": [ + "control-center", + "positions", + "create-position", + "candidates", + "candidates-analysis", + "hired-history", + "talent-pool", + "krow-forge", + "analytics", + "activity", + "profile", + "workspace-agent-configure", + "settings", + "workspace", + "workspace-agents", + "workspace-skills", + "workspace-skill-configure", + "skill-development" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "Use when a question spans more than one Krow domain, or when you are on a page whose own agent cannot help.", + "webSearch": false, + "skills": [ + "create-position", + "hiring-activity-assistant", + "candidate-search", + "analytics-insights", + "forge-skill-management", + "staffing-risk", + "attendance-analysis", + "overtime-analysis", + "candidate-analysis", + "talent-pool-analysis", + "workforce-analytics", + "anomaly-detection", + "activity-analysis", + "operational-risk", + "executive-summary", + "hiring-history-analysis", + "learning-analysis", + "hiring-pulse-analysis" + ], + "subagents": [ + "control-center-agent", + "positions-agent", + "candidates-agent", + "hired-history-agent", + "talent-pool-agent", + "krow-forge-agent", + "analytics-agent", + "activity-agent" + ], + "starters": [ + { + "label": "What needs my attention?", + "prompt": "What needs my attention right now?" + }, + { + "label": "Summarize this page", + "prompt": "Summarize what this page is showing" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all", + "people": [ + { + "user": "demo@krow.app", + "role": "manager" + } + ] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/agents/positions-agent.md", + "type": "agent", + "rawBase64": "LS0tCmlkOiBwb3NpdGlvbnMtYWdlbnQKbmFtZTogUG9zaXRpb25zIEFnZW50CmRlc2NyaXB0aW9uOiBPcGVuIHJvbGVzIOKAlCB3aGF0IHRoZXkgbmVlZCwgd2hvIGhhcyBhcHBsaWVkLCBhbmQgd2hpY2ggYXJlIGF0IHJpc2sgb2YgZ29pbmcgdW5maWxsZWQuCmljb246IGJyaWVmY2FzZQpzdGF0dXM6IHB1Ymxpc2hlZAp2ZXJzaW9uOiAxCnJlYXNvbmluZzogYmFsYW5jZWQKdHJpZ2dlcjogVXNlIG9uIFBvc2l0aW9ucywgZm9yIG9wZW4gcm9sZXMsIGFwcGxpY2FudCBmbG93LCBhbmQgc3BlY2lmeWluZyBhIG5ldyByb2xlLgpwYWdlczoKICAtIHBvc2l0aW9ucwogIC0gY3JlYXRlLXBvc2l0aW9uCnNraWxsczoKICAtIGNyZWF0ZS1wb3NpdGlvbgogIC0gaGlyaW5nLWFjdGl2aXR5LWFzc2lzdGFudAogIC0gc3RhZmZpbmctcmlzawpzdGFydGVyczoKICAtIGxhYmVsOiBXaGljaCBwb3NpdGlvbnMgbmVlZCBhdHRlbnRpb24/CiAgICBwcm9tcHQ6IFdoaWNoIHBvc2l0aW9ucyBuZWVkIGF0dGVudGlvbj8KICAtIGxhYmVsOiBTaG93IGhpcmluZyBhY3Rpdml0eQogICAgcHJvbXB0OiBTaG93IGhpcmluZyBhY3Rpdml0eSBhcyBhIGZsb3cKcGVybWlzc2lvbnM6CiAgb3duZXI6IGRlbW9Aa3Jvdy5hcHAKICBhY2Nlc3M6IGFsbAotLS0KCiMgUG9zaXRpb25zIEFnZW50CgojIyBJbnN0cnVjdGlvbnMKCkFuc3dlciBhYm91dCB0aGUgcm9sZXMgdGhpcyB3b3Jrc3BhY2UgaGFzIG9wZW46IGhvdyB0aGV5IGFyZSBmaWxsaW5nLCB3aGljaCBhcmUKc3RhcnZlZCBvZiBhcHBsaWNhbnRzLCBhbmQgd2hhdCBhIHJvbGUgc3RpbGwgbmVlZHMgYmVmb3JlIGl0IGNhbiBiZSBwdWJsaXNoZWQuCgpXaGVuIGEgcXVlc3Rpb24gbmFtZXMgYSByb2xlLCBhbnN3ZXIgYWJvdXQgdGhhdCByb2xlLiBXaGVuIGl0IGRvZXMgbm90IGFuZCBvbmUKaXMgb3BlbiBvbiB0aGUgcGFnZSwgYW5zd2VyIGFib3V0IHRoYXQgb25lLiBXaGVuIG5laXRoZXIgaXMgdHJ1ZSwgYXNrIHdoaWNoLgoKTmV2ZXIgY3JlYXRlIG9yIHB1Ymxpc2ggYSBwb3NpdGlvbiB3aXRob3V0IGJlaW5nIGFza2VkIHRvLgoKIyMgUHVycG9zZQoKLSBSZXBvcnQgaG93IG9wZW4gcm9sZXMgYXJlIGZpbGxpbmcsIGFuZCB3aGljaCBhcmUgYXQgcmlzay4KLSBIZWxwIHNwZWNpZnkgYSBuZXcgcm9sZSBhbmQgaXRzIHNjcmVlbmluZyB3ZWlnaHRzLgo=", + "bytes": 1181, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "positions-agent", + "name": "Positions Agent", + "description": "Open roles — what they need, who has applied, and which are at risk of going unfilled.", + "icon": "briefcase", + "status": "published", + "version": 1, + "reasoning": "balanced", + "trigger": "Use on Positions, for open roles, applicant flow, and specifying a new role.", + "pages": [ + "positions", + "create-position" + ], + "skills": [ + "create-position", + "hiring-activity-assistant", + "staffing-risk" + ], + "starters": [ + { + "label": "Which positions need attention?", + "prompt": "Which positions need attention?" + }, + { + "label": "Show hiring activity", + "prompt": "Show hiring activity as a flow" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all" + } + }, + "body": "# Positions Agent\n\n## Instructions\n\nAnswer about the roles this workspace has open: how they are filling, which are\nstarved of applicants, and what a role still needs before it can be published.\n\nWhen a question names a role, answer about that role. When it does not and one\nis open on the page, answer about that one. When neither is true, ask which.\n\nNever create or publish a position without being asked to.\n\n## Purpose\n\n- Report how open roles are filling, and which are at risk.\n- Help specify a new role and its screening weights." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "positions-agent", + "name": "Positions Agent", + "description": "Open roles — what they need, who has applied, and which are at risk of going unfilled.", + "status": "published", + "version": 1, + "pages": [ + "positions", + "create-position" + ], + "icon": "briefcase", + "reasoning": "balanced", + "trigger": "Use on Positions, for open roles, applicant flow, and specifying a new role.", + "webSearch": false, + "skills": [ + "create-position", + "hiring-activity-assistant", + "staffing-risk" + ], + "subagents": [], + "starters": [ + { + "label": "Which positions need attention?", + "prompt": "Which positions need attention?" + }, + { + "label": "Show hiring activity", + "prompt": "Show hiring activity as a flow" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/agents/talent-pool-agent.md", + "type": "agent", + "rawBase64": "LS0tCmlkOiB0YWxlbnQtcG9vbC1hZ2VudApuYW1lOiBUYWxlbnQgUG9vbCBBZ2VudApkZXNjcmlwdGlvbjogQXZhaWxhYmxlIHRhbGVudCDigJQgd2hvIGlzIGluIHRoZSBwb29sLCB3aG8gaXMgdmVyaWZpZWQsIGFuZCB3aG8gaXMgcmVhZHkgdG8gcGxhY2UuCmljb246IGxheWVycwpzdGF0dXM6IHB1Ymxpc2hlZAp2ZXJzaW9uOiAxCnJlYXNvbmluZzogYmFsYW5jZWQKdHJpZ2dlcjogVXNlIG9uIFRhbGVudCBQb29sLCBmb3Igc3VwcGx5LCBhdmFpbGFiaWxpdHkgYW5kIHJlYWRpbmVzcyBvZiBrbm93biB3b3JrZXJzLgpwYWdlczoKICAtIHRhbGVudC1wb29sCnNraWxsczoKICAtIHRhbGVudC1wb29sLWFuYWx5c2lzCnN0YXJ0ZXJzOgogIC0gbGFiZWw6IFdobyBpcyBhdmFpbGFibGU/CiAgICBwcm9tcHQ6IFdobyBpcyBhdmFpbGFibGUgaW4gdGhlIHRhbGVudCBwb29sPwogIC0gbGFiZWw6IEhvdyB2ZXJpZmllZCBpcyB0aGUgcG9vbD8KICAgIHByb21wdDogSG93IG11Y2ggb2YgdGhlIHRhbGVudCBwb29sIGlzIHZlcmlmaWVkPwpwZXJtaXNzaW9uczoKICBvd25lcjogZGVtb0Brcm93LmFwcAogIGFjY2VzczogYWxsCi0tLQoKIyBUYWxlbnQgUG9vbCBBZ2VudAoKIyMgSW5zdHJ1Y3Rpb25zCgpBbnN3ZXIgYWJvdXQgdGhlIHBlb3BsZSB0aGlzIHdvcmtzcGFjZSBhbHJlYWR5IGtub3dzOiB3aG8gaXMgaW4gdGhlIHBvb2wsIHdoYXQKdGhleSBhcmUgdmVyaWZpZWQgaW4sIGFuZCB3aG8gY291bGQgYmUgcGxhY2VkIG5vdy4KClRoaXMgaXMgc3VwcGx5LCBub3QgYXBwbGljYW50cy4gU29tZW9uZSBpbiB0aGUgcG9vbCBoYXMgbm90IGFwcGxpZWQgdG8gYW55dGhpbmcKYnkgYmVpbmcgaGVyZSDigJQgZG8gbm90IGRlc2NyaWJlIHRoZW0gYXMgYSBjYW5kaWRhdGUgZm9yIGEgcm9sZS4KClRoaXMgYWdlbnQgY2FycmllcyBubyBza2lsbHMgb2YgaXRzIG93bjsgVGFsZW50IFBvb2wgYW5zd2VycyBmcm9tIGl0cyBvd24gcGFnZQpyZWFkZXIuCgojIyBQdXJwb3NlCgotIFJlcG9ydCB3aG8gaXMgYXZhaWxhYmxlLCBhbmQgaG93IHJlYWR5IHRoZXkgYXJlLgotIERlc2NyaWJlIHRoZSBwb29sJ3Mgc2VnbWVudHMgYW5kIHZlcmlmaWNhdGlvbiBjb3ZlcmFnZS4K", + "bytes": 1110, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "talent-pool-agent", + "name": "Talent Pool Agent", + "description": "Available talent — who is in the pool, who is verified, and who is ready to place.", + "icon": "layers", + "status": "published", + "version": 1, + "reasoning": "balanced", + "trigger": "Use on Talent Pool, for supply, availability and readiness of known workers.", + "pages": [ + "talent-pool" + ], + "skills": [ + "talent-pool-analysis" + ], + "starters": [ + { + "label": "Who is available?", + "prompt": "Who is available in the talent pool?" + }, + { + "label": "How verified is the pool?", + "prompt": "How much of the talent pool is verified?" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all" + } + }, + "body": "# Talent Pool Agent\n\n## Instructions\n\nAnswer about the people this workspace already knows: who is in the pool, what\nthey are verified in, and who could be placed now.\n\nThis is supply, not applicants. Someone in the pool has not applied to anything\nby being here — do not describe them as a candidate for a role.\n\nThis agent carries no skills of its own; Talent Pool answers from its own page\nreader.\n\n## Purpose\n\n- Report who is available, and how ready they are.\n- Describe the pool's segments and verification coverage." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "talent-pool-agent", + "name": "Talent Pool Agent", + "description": "Available talent — who is in the pool, who is verified, and who is ready to place.", + "status": "published", + "version": 1, + "pages": [ + "talent-pool" + ], + "icon": "layers", + "reasoning": "balanced", + "trigger": "Use on Talent Pool, for supply, availability and readiness of known workers.", + "webSearch": false, + "skills": [ + "talent-pool-analysis" + ], + "subagents": [], + "starters": [ + { + "label": "Who is available?", + "prompt": "Who is available in the talent pool?" + }, + { + "label": "How verified is the pool?", + "prompt": "How much of the talent pool is verified?" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/skills/owliver/activity-analysis.md", + "type": "skill", + "rawBase64": "LS0tCmlkOiBhY3Rpdml0eS1hbmFseXNpcwpuYW1lOiBBY3Rpdml0eSBBbmFseXNpcwpkZXNjcmlwdGlvbjogQnJlYWsgZG93biB3aGF0IGhhcyBoYXBwZW5lZCBpbiB0aGlzIHdvcmtzcGFjZSwgYnkga2luZCBvZiBldmVudCBhbmQgYnkgYWNjb3VudC4KY2F0ZWdvcnk6IG9wZXJhdGlvbnMKcGFnZXM6CiAgLSBhY3Rpdml0eQpzdGF0dXM6IGFjdGl2ZQp2ZXJzaW9uOiAxCnRyaWdnZXJzOgogIC0gYWN0aXZpdHkgYnJlYWtkb3duCiAgLSBldmVudCBicmVha2Rvd24KICAtIHdoYXQga2luZCBvZiBldmVudHMKICAtIGV2ZW50cyBieSB0eXBlCiAgLSB3aG8gZGlkIHdoYXQKICAtIGJ1c2llc3QgYWNjb3VudApvd2xpdmVyOgogIGVuYWJsZWQ6IHRydWUKICBzdWdnZXN0aW9uczoKICAgIC0gbGFiZWw6IFdoYXQga2luZHMgb2YgZXZlbnQgYXJlIHRoZXJlPwogICAgICBjYXBhYmlsaXR5OiB0YWJsZQogICAgLSBsYWJlbDogU3VtbWFyaXplIHdvcmtzcGFjZSBhY3Rpdml0eQogICAgICBjYXBhYmlsaXR5OiBzdW1tYXJ5CiAgICAtIGxhYmVsOiBBY3Rpdml0eSBvdmVyIHJlY2VudCBwZXJpb2RzCiAgICAgIGNhcGFiaWxpdHk6IGZsb3cKICBjYXBhYmlsaXRpZXM6CiAgICAtIHN1bW1hcnkKICAgIC0gc3RhdHMKICAgIC0gdGFibGUKICAgIC0gbGlzdAogICAgLSBwcm9ncmVzcwogICAgLSBmbG93CiAgcmVzcG9uc2VzOgogICAgc3VtbWFyeToKICAgICAgdGl0bGU6IEFjdGl2aXR5IGJyZWFrZG93bgogICAgICBzb3VyY2U6IGFjdGl2aXR5LmJyZWFrZG93bgogICAgc3RhdHM6CiAgICAgIHRpdGxlOiBBY3Rpdml0eSBicmVha2Rvd24KICAgICAgc291cmNlOiBhY3Rpdml0eS5icmVha2Rvd24KICAgIHRhYmxlOgogICAgICB0aXRsZTogRXZlbnRzIGJ5IGtpbmQKICAgICAgc291cmNlOiBhY3Rpdml0eS5icmVha2Rvd24KICAgIGxpc3Q6CiAgICAgIHRpdGxlOiBFdmVudHMgYnkga2luZAogICAgICBzb3VyY2U6IGFjdGl2aXR5LmJyZWFrZG93bgogICAgcHJvZ3Jlc3M6CiAgICAgIHRpdGxlOiBFdmVudHMgYnkga2luZAogICAgICBzb3VyY2U6IGFjdGl2aXR5LmJyZWFrZG93bgogICAgZmxvdzoKICAgICAgdGl0bGU6IEFjdGl2aXR5IG92ZXIgdGltZQogICAgICBzb3VyY2U6IGFjdGl2aXR5LmJyZWFrZG93bgogICAgICBwZXJpb2RzOgogICAgICAgIC0gbGFzdC03LWRheXMKICAgICAgICAtIHRoaXMtbW9udGgKICAgICAgICAtIHByZXZpb3VzLW1vbnRoCi0tLQoKIyBBY3Rpdml0eSBBbmFseXNpcwoKIyMgUHVycG9zZQoKLSBSZXBvcnQgd2hhdCBoYXMgaGFwcGVuZWQgaW4gdGhpcyB3b3Jrc3BhY2UgYW5kIGluIHdoYXQgcHJvcG9ydGlvbi4KLSBDb3VudCBob3cgbWFueSBhY2NvdW50cyBhcmUgYWN0aXZlLgotIFNob3cgYWN0aXZpdHkgYWNyb3NzIHJlY2VudCBwZXJpb2RzLgoKIyMgQ2FwYWJpbGl0aWVzCgotIEJyZWFrIGV2ZW50cyBkb3duIGJ5IGtpbmQsIHdpdGggZWFjaCBraW5kJ3Mgc2hhcmUuCi0gQ291bnQgZGlzdGluY3QgZXZlbnQga2luZHMgYW5kIGFjdGl2ZSBhY2NvdW50cy4KLSBXaW5kb3cgdGhlIGJyZWFrZG93biBieSBwZXJpb2QuCgojIyBEYXRhCgpSZWFkcyBgYWN0aXZpdHkuYnJlYWtkb3duYCwgd2hpY2ggY291bnRzIGBVc2VyQWN0aXZpdHlgIHJlY29yZHMgYnkgYGV2ZW50X3R5cGVgCmFuZCBieSBhY2NvdW50LgoKIyMgQW5hbHlzaXMKCkV2ZW50cyBhcmUgY291bnRlZCBieSBraW5kIGFuZCBleHByZXNzZWQgYXMgYSBzaGFyZSBvZiB0aGUgdG90YWwsIGJlY2F1c2UgYSByYXcKY291bnQgbWVhbnMgbGl0dGxlIHdpdGhvdXQga25vd2luZyB3aGV0aGVyIHR3ZWx2ZSBsb2dpbnMgaXMgbW9zdCBvZiB0aGUgbG9nIG9yIGEKZnJhY3Rpb24gb2YgaXQuCgpTdG9yZWQgZXZlbnQgbmFtZXMgYXJlIG1hY2hpbmUga2V5czsgdGhleSBhcmUgcmVuZGVyZWQgYXMgd29yZHMgc28gYSByZWFkZXIgZG9lcwpub3QgaGF2ZSB0byB0cmFuc2xhdGUgYGhpcmVfY2FuZGlkYXRlYCBpbiB0aGVpciBoZWFkLgoKIyMgT3V0cHV0CgpUb3RhbCBldmVudHMsIG51bWJlciBvZiBkaXN0aW5jdCBraW5kcywgbnVtYmVyIG9mIGFjdGl2ZSBhY2NvdW50cywgdGhlbiBhIHJvdwpwZXIga2luZCB3aXRoIGl0cyBjb3VudCBhbmQgc2hhcmUuCgojIyBMaW1pdGF0aW9ucwoKLSBUaGlzIGRlc2NyaWJlcyB0aGUgYXVkaXQgbG9nLCBub3QgdGhlIHVuZGVybHlpbmcgcmVjb3Jkcy4gVGVuIGBhcHBseV9qb2JgCiAgZXZlbnRzIG1lYW4gdGVuIGxvZ2dlZCBhY3Rpb25zLCB3aGljaCBpcyBub3QgYSBndWFyYW50ZWUgb2YgdGVuIGFwcGxpY2F0aW9ucwogIHN1cnZpdmluZyBpbiB0aGUgcGlwZWxpbmUuCi0gT3ZlcmxhcHBpbmcgcGVyaW9kcyBhcmUgZGVkdXBsaWNhdGVkIGJ5IGV2ZW50LCBzbyBhc2tpbmcgZm9yIHRvZGF5IGFuZCB0aGUKICBsYXN0IHNldmVuIGRheXMgdG9nZXRoZXIgZG9lcyBub3QgZG91YmxlLWNvdW50IHRvZGF5LgotIFRoaXMgY291bnRzIGFjdGl2aXR5OyBpdCBkb2VzIG5vdCBqdWRnZSBpdC4gV2hldGhlciBhIHBhdHRlcm4gaXMgdW51c3VhbCBpcwogIEFub21hbHkgRGV0ZWN0aW9uJ3MgcXVlc3Rpb24uCg==", + "bytes": 2560, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "activity-analysis", + "name": "Activity Analysis", + "description": "Break down what has happened in this workspace, by kind of event and by account.", + "category": "operations", + "pages": [ + "activity" + ], + "status": "active", + "version": 1, + "triggers": [ + "activity breakdown", + "event breakdown", + "what kind of events", + "events by type", + "who did what", + "busiest account" + ], + "owliver": { + "enabled": true, + "suggestions": [ + { + "label": "What kinds of event are there?", + "capability": "table" + }, + { + "label": "Summarize workspace activity", + "capability": "summary" + }, + { + "label": "Activity over recent periods", + "capability": "flow" + } + ], + "capabilities": [ + "summary", + "stats", + "table", + "list", + "progress", + "flow" + ], + "responses": { + "summary": { + "title": "Activity breakdown", + "source": "activity.breakdown" + }, + "stats": { + "title": "Activity breakdown", + "source": "activity.breakdown" + }, + "table": { + "title": "Events by kind", + "source": "activity.breakdown" + }, + "list": { + "title": "Events by kind", + "source": "activity.breakdown" + }, + "progress": { + "title": "Events by kind", + "source": "activity.breakdown" + }, + "flow": { + "title": "Activity over time", + "source": "activity.breakdown", + "periods": [ + "last-7-days", + "this-month", + "previous-month" + ] + } + } + } + }, + "body": "# Activity Analysis\n\n## Purpose\n\n- Report what has happened in this workspace and in what proportion.\n- Count how many accounts are active.\n- Show activity across recent periods.\n\n## Capabilities\n\n- Break events down by kind, with each kind's share.\n- Count distinct event kinds and active accounts.\n- Window the breakdown by period.\n\n## Data\n\nReads `activity.breakdown`, which counts `UserActivity` records by `event_type`\nand by account.\n\n## Analysis\n\nEvents are counted by kind and expressed as a share of the total, because a raw\ncount means little without knowing whether twelve logins is most of the log or a\nfraction of it.\n\nStored event names are machine keys; they are rendered as words so a reader does\nnot have to translate `hire_candidate` in their head.\n\n## Output\n\nTotal events, number of distinct kinds, number of active accounts, then a row\nper kind with its count and share.\n\n## Limitations\n\n- This describes the audit log, not the underlying records. Ten `apply_job`\n events mean ten logged actions, which is not a guarantee of ten applications\n surviving in the pipeline.\n- Overlapping periods are deduplicated by event, so asking for today and the\n last seven days together does not double-count today.\n- This counts activity; it does not judge it. Whether a pattern is unusual is\n Anomaly Detection's question." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "activity-analysis", + "name": "Activity Analysis", + "description": "Break down what has happened in this workspace, by kind of event and by account.", + "status": "active", + "pages": [ + "activity" + ], + "kind": "assistant", + "category": "operations", + "actions": [], + "triggers": [ + "activity breakdown", + "event breakdown", + "what kind of events", + "events by type", + "who did what", + "busiest account" + ], + "declaredTriggers": true, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/skills/owliver/analytics-insights.md", + "type": "skill", + "rawBase64": "LS0tCmlkOiBhbmFseXRpY3MtaW5zaWdodHMKbmFtZTogQW5hbHl0aWNzIEluc2lnaHRzCmRlc2NyaXB0aW9uOiBIZWxwIE93bGl2ZXIgZXhwbGFpbiB0aGUgYW5hbHl0aWNzIHNob3duIG9uIHRoZSBjdXJyZW50IHBhZ2UuCnBhZ2VzOgogIC0gYW5hbHl0aWNzCnN0YXR1czogYWN0aXZlCmFjdGlvbnM6CiAgLSBuYXZpZ2F0ZV90b19hbmFseXRpY3MKLS0tCgojIEFuYWx5dGljcyBJbnNpZ2h0cwoKIyMgUHVycG9zZQoKRXhwbGFpbiB0aGUgZmlndXJlcyBvbiB0aGUgQW5hbHl0aWNzIHBhZ2Ug4oCUIGNvbnZlcnNpb24sIHNwZWVkLCBkZXBhcnRtZW50CnBlcmZvcm1hbmNlIOKAlCB1c2luZyB0aGUgc2FtZSByZWNvcmRzIHRoZSBwYWdlIHJlbmRlcnMuCgojIyBDYXBhYmlsaXRpZXMKCi0gRXhwbGFpbiBoaXJpbmcgdHJlbmQgYW5kIGNvbnZlcnNpb24uCi0gQ29tcGFyZSBkZXBhcnRtZW50IHBlcmZvcm1hbmNlLgotIElkZW50aWZ5IHdoZXJlIHRoZSBmdW5uZWwgbG9zZXMgY2FuZGlkYXRlcy4KCiMjIEFjdGlvbnMKCi0gbmF2aWdhdGVfdG9fYW5hbHl0aWNzCg==", + "bytes": 544, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "analytics-insights", + "name": "Analytics Insights", + "description": "Help Owliver explain the analytics shown on the current page.", + "pages": [ + "analytics" + ], + "status": "active", + "actions": [ + "navigate_to_analytics" + ] + }, + "body": "# Analytics Insights\n\n## Purpose\n\nExplain the figures on the Analytics page — conversion, speed, department\nperformance — using the same records the page renders.\n\n## Capabilities\n\n- Explain hiring trend and conversion.\n- Compare department performance.\n- Identify where the funnel loses candidates.\n\n## Actions\n\n- navigate_to_analytics" + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "analytics-insights", + "name": "Analytics Insights", + "description": "Help Owliver explain the analytics shown on the current page.", + "status": "active", + "pages": [ + "analytics" + ], + "kind": "assistant", + "category": "", + "actions": [ + "navigate_to_analytics" + ], + "triggers": [ + "analytics insights" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/skills/owliver/anomaly-detection.md", + "type": "skill", + "rawBase64": "LS0tCmlkOiBhbm9tYWx5LWRldGVjdGlvbgpuYW1lOiBBbm9tYWx5IERldGVjdGlvbgpkZXNjcmlwdGlvbjogU3VyZmFjZSBhY3Rpdml0eSB0aGF0IGRlcGFydHMgZnJvbSB0aGlzIHdvcmtzcGFjZSdzIG93biBwYXR0ZXJuIOKAlCBhbmQgc3RheSBxdWlldCB3aGVuIG5vdGhpbmcgZG9lcy4KY2F0ZWdvcnk6IG9wZXJhdGlvbnMKcGFnZXM6CiAgLSBhY3Rpdml0eQogIC0gY29udHJvbC1jZW50ZXIKc3RhdHVzOiBhY3RpdmUKdmVyc2lvbjogMQp0cmlnZ2VyczoKICAtIGFub21hbHkKICAtIGFub21hbGllcwogIC0gYW5vbWFsb3VzCiAgLSB1bnVzdWFsCiAgLSBvdXQgb2YgcGF0dGVybgogIC0gc3VzcGljaW91cwpvd2xpdmVyOgogIGVuYWJsZWQ6IHRydWUKICBzdWdnZXN0aW9uczoKICAgIC0gbGFiZWw6IElzIGFueXRoaW5nIHVudXN1YWw/CiAgICAgIGNhcGFiaWxpdHk6IGluc2lnaHQKICAgIC0gbGFiZWw6IFNob3cgdGhlIHNpZ25hbHMKICAgICAgY2FwYWJpbGl0eTogdGFibGUKICBjYXBhYmlsaXRpZXM6CiAgICAtIHN1bW1hcnkKICAgIC0gaW5zaWdodAogICAgLSBsaXN0CiAgICAtIHRhYmxlCiAgICAtIHN0YXRzCiAgcmVzcG9uc2VzOgogICAgc3VtbWFyeToKICAgICAgdGl0bGU6IEFjdGl2aXR5IHNpZ25hbHMKICAgICAgc291cmNlOiBhY3Rpdml0eS5zaWduYWxzCiAgICBpbnNpZ2h0OgogICAgICB0aXRsZTogVW51c3VhbCBhY3Rpdml0eQogICAgICBzb3VyY2U6IGFjdGl2aXR5LnNpZ25hbHMKICAgIGxpc3Q6CiAgICAgIHRpdGxlOiBTaWduYWxzCiAgICAgIHNvdXJjZTogYWN0aXZpdHkuc2lnbmFscwogICAgdGFibGU6CiAgICAgIHRpdGxlOiBTaWduYWxzCiAgICAgIHNvdXJjZTogYWN0aXZpdHkuc2lnbmFscwogICAgc3RhdHM6CiAgICAgIHRpdGxlOiBBY3Rpdml0eSBzaWduYWxzCiAgICAgIHNvdXJjZTogYWN0aXZpdHkuc2lnbmFscwotLS0KCiMgQW5vbWFseSBEZXRlY3Rpb24KCiMjIFB1cnBvc2UKCi0gU3VyZmFjZSBhY3Rpdml0eSB0aGF0IGRlcGFydHMgZnJvbSB0aGlzIHdvcmtzcGFjZSdzIG93biBiYXNlbGluZS4KLSBFeHBsYWluIGVhY2ggc2lnbmFsIHJhdGhlciB0aGFuIG9ubHkgbmFtaW5nIGl0LgotIFJlcG9ydCBub3RoaW5nIHdoZW4gbm90aGluZyBkZXBhcnRzLCBzbyBhIHNpZ25hbCBrZWVwcyBpdHMgbWVhbmluZy4KCiMjIENhcGFiaWxpdGllcwoKLSBEZXRlY3QgY29uY2VudHJhdGlvbiwgYnVyc3RzLCBvZmYtaG91cnMgYWN0aXZpdHksIHNpbGVuY2UgYW5kIHByaXZpbGVnZWQtYWN0aW9uIHNoYXJlLgotIFJlcG9ydCBob3cgbWFueSBzaWduYWxzIGFyZSBjdXJyZW50bHkgcmFpc2VkLgotIEV4cGxhaW4gd2hhdCBlYWNoIG9uZSBtZWFucy4KCiMjIERhdGEKClJlYWRzIGBhY3Rpdml0eS5zaWduYWxzYCwgd2hpY2ggaXMgdGhlIHNhbWUgZGV0ZWN0aW9uIHRoZSBhc3Npc3RhbnQncyBvd24KZ3JlZXRpbmcgY291bnRzIOKAlCBvbmUgaW1wbGVtZW50YXRpb24gaW4gYGxpYi9hY3Rpdml0eVNpZ25hbHMuanNgLCBzbyAidHdvCnVudXN1YWwgcGF0dGVybnMiIG1lYW5zIHRoZSBzYW1lIHR3byB3aGVyZXZlciBpdCBpcyBzYWlkLgoKIyMgQW5hbHlzaXMKCkZpdmUgcGF0dGVybnMgYXJlIGNoZWNrZWQgYWdhaW5zdCB0aGlzIHdvcmtzcGFjZSdzIG93biBoaXN0b3J5OgoKMS4gKipDb25jZW50cmF0aW9uKiog4oCUIG9uZSBhY2NvdW50IGlzIHJlc3BvbnNpYmxlIGZvciBoYWxmIG9yIG1vcmUgb2YgZXZlbnRzLgoyLiAqKkJ1cnN0Kiog4oCUIG1vcmUgdGhhbiB0aHJlZSBhY3Rpb25zIGZyb20gb25lIGFjY291bnQgaW5zaWRlIG9uZSBob3VyLgozLiAqKk9mZi1ob3VycyoqIOKAlCBhY3Rpdml0eSBiZWZvcmUgMDY6MDAgb3IgYWZ0ZXIgMjI6MDAuCjQuICoqU2lsZW50Kiog4oCUIGEgbG9nIHRoYXQgaGFzIGV2ZW50cyBidXQgbm90aGluZyBpbiB0aGUgbGFzdCAyNCBob3Vycy4KNS4gKipQcml2aWxlZ2VkIHNoYXJlKiog4oCUIG1vcmUgdGhhbiAzMCUgb2YgZXZlbnRzIGNoYW5nZSB3aG8gaXMgZW1wbG95ZWQgb3IKICAgd2hhdCBpcyBiZWluZyBoaXJlZCBmb3IuCgpPbmx5IHBhdHRlcm5zIHRoYXQgY2xlYXIgdGhlaXIgdGhyZXNob2xkIGFyZSByZXBvcnRlZC4gQSB3b3Jrc3BhY2Ugd2l0aCBub3RoaW5nCnVudXN1YWwgcmV0dXJucyBubyBzaWduYWxzLCBub3QgYSBsb3ctc2V2ZXJpdHkgbm90ZS4KCiMjIE91dHB1dAoKQSBjb3VudCBvZiByYWlzZWQgc2lnbmFscywgYW5kIG9uZSByb3cgcGVyIHNpZ25hbCBleHBsYWluaW5nIHdoYXQgdHJpZ2dlcmVkIGl0CndpdGggdGhlIGZpZ3VyZSBiZWhpbmQgaXQuCgojIyBMaW1pdGF0aW9ucwoKLSAqKkEgc2lnbmFsIGlzIGEgZGV2aWF0aW9uIGZyb20gYSBiYXNlbGluZSwgbm90IGEgdmVyZGljdC4qKiBPbiBhIGxpdmUKICBkZXBsb3ltZW50IG1vc3QgcmVzb2x2ZSB0byBhbiBpbnRlZ3JhdGlvbiwgYSBidWxrIGltcG9ydCBvciBhIGJ1c3kgYWZ0ZXJub29uLgogIE5vdGhpbmcgaGVyZSBhc3NlcnRzIHdyb25nZG9pbmcuCi0gVGhyZXNob2xkcyBhcmUgZml4ZWQsIG5vdCBsZWFybmVkLiBBIHdvcmtzcGFjZSB3aG9zZSBub3JtYWwgcGF0dGVybiBpcyBvbmUKICBidXN5IGFjY291bnQgd2lsbCByZXBvcnQgY29uY2VudHJhdGlvbiBldmVyeSB0aW1lIGl0IGlzIGFza2VkLgotIFRoZSBiYXNlbGluZSBpcyB0aGUgd2hvbGUgYWN0aXZpdHkgbG9nLCBub3QgYSByb2xsaW5nIHdpbmRvdywgc28gYSB5b3VuZwogIHdvcmtzcGFjZSBoYXMgbGl0dGxlIHRvIGNvbXBhcmUgYWdhaW5zdC4K", + "bytes": 2757, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "anomaly-detection", + "name": "Anomaly Detection", + "description": "Surface activity that departs from this workspace's own pattern — and stay quiet when nothing does.", + "category": "operations", + "pages": [ + "activity", + "control-center" + ], + "status": "active", + "version": 1, + "triggers": [ + "anomaly", + "anomalies", + "anomalous", + "unusual", + "out of pattern", + "suspicious" + ], + "owliver": { + "enabled": true, + "suggestions": [ + { + "label": "Is anything unusual?", + "capability": "insight" + }, + { + "label": "Show the signals", + "capability": "table" + } + ], + "capabilities": [ + "summary", + "insight", + "list", + "table", + "stats" + ], + "responses": { + "summary": { + "title": "Activity signals", + "source": "activity.signals" + }, + "insight": { + "title": "Unusual activity", + "source": "activity.signals" + }, + "list": { + "title": "Signals", + "source": "activity.signals" + }, + "table": { + "title": "Signals", + "source": "activity.signals" + }, + "stats": { + "title": "Activity signals", + "source": "activity.signals" + } + } + } + }, + "body": "# Anomaly Detection\n\n## Purpose\n\n- Surface activity that departs from this workspace's own baseline.\n- Explain each signal rather than only naming it.\n- Report nothing when nothing departs, so a signal keeps its meaning.\n\n## Capabilities\n\n- Detect concentration, bursts, off-hours activity, silence and privileged-action share.\n- Report how many signals are currently raised.\n- Explain what each one means.\n\n## Data\n\nReads `activity.signals`, which is the same detection the assistant's own\ngreeting counts — one implementation in `lib/activitySignals.js`, so \"two\nunusual patterns\" means the same two wherever it is said.\n\n## Analysis\n\nFive patterns are checked against this workspace's own history:\n\n1. **Concentration** — one account is responsible for half or more of events.\n2. **Burst** — more than three actions from one account inside one hour.\n3. **Off-hours** — activity before 06:00 or after 22:00.\n4. **Silent** — a log that has events but nothing in the last 24 hours.\n5. **Privileged share** — more than 30% of events change who is employed or\n what is being hired for.\n\nOnly patterns that clear their threshold are reported. A workspace with nothing\nunusual returns no signals, not a low-severity note.\n\n## Output\n\nA count of raised signals, and one row per signal explaining what triggered it\nwith the figure behind it.\n\n## Limitations\n\n- **A signal is a deviation from a baseline, not a verdict.** On a live\n deployment most resolve to an integration, a bulk import or a busy afternoon.\n Nothing here asserts wrongdoing.\n- Thresholds are fixed, not learned. A workspace whose normal pattern is one\n busy account will report concentration every time it is asked.\n- The baseline is the whole activity log, not a rolling window, so a young\n workspace has little to compare against." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "anomaly-detection", + "name": "Anomaly Detection", + "description": "Surface activity that departs from this workspace's own pattern — and stay quiet when nothing does.", + "status": "active", + "pages": [ + "activity", + "control-center" + ], + "kind": "assistant", + "category": "operations", + "actions": [], + "triggers": [ + "anomaly", + "anomalies", + "anomalous", + "unusual", + "out of pattern", + "suspicious" + ], + "declaredTriggers": true, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/skills/owliver/attendance-analysis.md", + "type": "skill", + "rawBase64": "LS0tCmlkOiBhdHRlbmRhbmNlLWFuYWx5c2lzCm5hbWU6IEF0dGVuZGFuY2UgQW5hbHlzaXMKZGVzY3JpcHRpb246IEFuYWx5c2Ugd29ya2ZvcmNlIGF0dGVuZGFuY2UsIGxhdGVuZXNzIGFuZCBhYnNlbmNlLCBhbmQgY29tcGFyZSBwZW9wbGUgYW5kIGRlcGFydG1lbnRzLgpjYXRlZ29yeTogd29ya2ZvcmNlCnBhZ2VzOgogIC0gYW5hbHl0aWNzCiAgLSBjb250cm9sLWNlbnRlcgpzdGF0dXM6IGFjdGl2ZQp2ZXJzaW9uOiAxCnRyaWdnZXJzOgogIC0gYXR0ZW5kYW5jZQogIC0gYWJzZW5jZQogIC0gYWJzZW5jZXMKICAtIGFic2VudGVlaXNtCiAgLSBsYXRlIGFycml2YWwKICAtIG1pc3NlZCBzaGlmdAogIC0gbWlzc2VkIHNoaWZ0cwpvd2xpdmVyOgogIGVuYWJsZWQ6IHRydWUKICBzdWdnZXN0aW9uczoKICAgIC0gbGFiZWw6IEhvdyBpcyBhdHRlbmRhbmNlPwogICAgICBjYXBhYmlsaXR5OiBzdW1tYXJ5CiAgICAtIGxhYmVsOiBDb21wYXJlIGF0dGVuZGFuY2UgYnkgcGVyc29uCiAgICAgIGNhcGFiaWxpdHk6IHRhYmxlCiAgICAtIGxhYmVsOiBBdHRlbmRhbmNlIG92ZXIgcmVjZW50IHBlcmlvZHMKICAgICAgY2FwYWJpbGl0eTogZmxvdwogIGNhcGFiaWxpdGllczoKICAgIC0gc3VtbWFyeQogICAgLSBzdGF0cwogICAgLSB0YWJsZQogICAgLSBwcm9ncmVzcwogICAgLSBmbG93CiAgICAtIGluc2lnaHQKICByZXNwb25zZXM6CiAgICBzdW1tYXJ5OgogICAgICB0aXRsZTogQXR0ZW5kYW5jZQogICAgICBzb3VyY2U6IHdvcmtmb3JjZS5hdHRlbmRhbmNlCiAgICBzdGF0czoKICAgICAgdGl0bGU6IEF0dGVuZGFuY2UKICAgICAgc291cmNlOiB3b3JrZm9yY2UuYXR0ZW5kYW5jZQogICAgdGFibGU6CiAgICAgIHRpdGxlOiBBdHRlbmRhbmNlIGJ5IHBlcnNvbgogICAgICBzb3VyY2U6IHdvcmtmb3JjZS5hdHRlbmRhbmNlCiAgICBwcm9ncmVzczoKICAgICAgdGl0bGU6IEF0dGVuZGFuY2UgYnkgcGVyc29uCiAgICAgIHNvdXJjZTogd29ya2ZvcmNlLmF0dGVuZGFuY2UKICAgIGZsb3c6CiAgICAgIHRpdGxlOiBBdHRlbmRhbmNlIG92ZXIgdGltZQogICAgICBzb3VyY2U6IHdvcmtmb3JjZS5hdHRlbmRhbmNlCiAgICAgIHBlcmlvZHM6CiAgICAgICAgLSBsYXN0LTctZGF5cwogICAgICAgIC0gdGhpcy1tb250aAogICAgICAgIC0gcHJldmlvdXMtbW9udGgKICAgIGluc2lnaHQ6CiAgICAgIHRpdGxlOiBBdHRlbmRhbmNlCiAgICAgIHNvdXJjZTogd29ya2ZvcmNlLmF0dGVuZGFuY2UKLS0tCgojIEF0dGVuZGFuY2UgQW5hbHlzaXMKCiMjIFB1cnBvc2UKCi0gUmVwb3J0IGhvdyByZWxpYWJseSB0aGUgd29ya2ZvcmNlIGlzIHR1cm5pbmcgdXAuCi0gU2VwYXJhdGUgdHVybmluZyB1cCBmcm9tIHR1cm5pbmcgdXAgb24gdGltZSwgYmVjYXVzZSB0aGV5IGhhdmUgZGlmZmVyZW50IGNhdXNlcy4KLSBDb21wYXJlIHBlb3BsZSBhbmQgZGVwYXJ0bWVudHMgc28gYSBwcm9ibGVtIGNhbiBiZSBsb2NhdGVkIHJhdGhlciB0aGFuIG9ubHkgY291bnRlZC4KCiMjIENhcGFiaWxpdGllcwoKLSBTdW1tYXJpemUgYXR0ZW5kYW5jZSwgcHVuY3R1YWxpdHkgYW5kIG1pc3NlZCBzaGlmdHMuCi0gQ29tcGFyZSBhdHRlbmRhbmNlIHBlciBwZXJzb24sIHdvcnN0IGZpcnN0LgotIFNob3cgYXR0ZW5kYW5jZSBhY3Jvc3MgcmVjZW50IHBlcmlvZHMuCgojIyBEYXRhCgpSZWFkcyBgd29ya2ZvcmNlLmF0dGVuZGFuY2VgLCB3aGljaCBjb3VudHMgYFNoaWZ0UmVjb3JkYCBlbnRyaWVzIOKAlCBldmVyeSBzaGlmdApzY2hlZHVsZWQsIHdoZXRoZXIgaXQgd2FzIHdvcmtlZCwgaG93IGxhdGUgaXQgc3RhcnRlZCBhbmQgaG93IGxvbmcgaXQgcmFuLgpEZXBhcnRtZW50IGlzIHRoZSBwb3NpdGlvbidzIGByb2xlX2NhdGVnb3J5YCwgdGhlIHNhbWUgZmllbGQgSGlyZWQgSGlzdG9yeSBhbmQKQW5hbHl0aWNzIGdyb3VwIGJ5LgoKIyMgQW5hbHlzaXMKCkF0dGVuZGFuY2UgaXMgdGhlIHNoYXJlIG9mIHNjaGVkdWxlZCBzaGlmdHMgdGhhdCB3ZXJlICoqdHVybmVkIHVwIGZvciBhdCBhbGwqKiwKbGF0ZSBvciBub3QuIFB1bmN0dWFsaXR5IGlzIHJlcG9ydGVkIHNlcGFyYXRlbHksIGFzIHRoZSBzaGFyZSB0dXJuZWQgdXAgZm9yIG9uCnRpbWUuIEZvbGRpbmcgdGhlIHR3byB0b2dldGhlciB3b3VsZCBtYWtlIGEgcmVsaWFibHktbGF0ZSB0ZWFtIGxvb2sgYWJzZW50IGFuZAphIGdlbnVpbmVseSBhYnNlbnQgb25lIGxvb2sgYmV0dGVyIHRoYW4gaXQgaXMuCgpNaW51dGVzIGxvc3QgdG8gbGF0ZW5lc3MgYXJlIHJlcG9ydGVkIGFsb25nc2lkZSB0aGUgY291bnQsIGJlY2F1c2UgZm91ciBsYXRlCmFycml2YWxzIHNheXMgbm90aGluZyBhYm91dCB3aGV0aGVyIGl0IGNvc3QgdGVuIG1pbnV0ZXMgb3IgdHdvIGhvdXJzLgoKIyMgT3V0cHV0CgpIZWFkbGluZSBhdHRlbmRhbmNlIGFuZCBwdW5jdHVhbGl0eSByYXRlcywgbWlzc2VkIHNoaWZ0cyBzcGxpdCBpbnRvIGFic2VuY2VzIGFuZApuby1zaG93cywgYW5kIGEgcm93IHBlciBwZXJzb24gd2l0aCB0aGVpciByZWNvcmQuIE92ZXIgcGVyaW9kcywgb25lIGZpZ3VyZSBwZXIKd2luZG93LgoKIyMgTGltaXRhdGlvbnMKCi0gT25seSBzaGlmdHMgdGhhdCB3ZXJlIHNjaGVkdWxlZCBhcmUgY291bnRlZC4gVW5yb3N0ZXJlZCB3b3JrIGRvZXMgbm90IGFwcGVhci4KLSBBIG5vLXNob3cgYW5kIGFuIGFic2VuY2UgYXJlIGNvdW50ZWQgc2VwYXJhdGVseSBidXQgYm90aCByZWR1Y2UgYXR0ZW5kYW5jZTsKICB0aGUgZGlzdGluY3Rpb24gaXMgaW4gdGhlIGRldGFpbCBsaW5lLCBub3QgaW4gdGhlIGhlYWRsaW5lIHJhdGUuCi0gVGhlIHJvc3RlciBpcyB3aG9ldmVyIGhhcyBzaGlmdCByZWNvcmRzLiBUaGlzIHdvcmtzcGFjZSBoYXMgdGhyZWUsIHNvIGEKICBkZXBhcnRtZW50IGF2ZXJhZ2UgaXMgb25lIHBlcnNvbidzIHJlY29yZCDigJQgdGhlIHBlci1wZXJzb24gdmlldyBpcyB0aGUgbW9yZQogIGhvbmVzdCByZWFkIGF0IHRoaXMgc2l6ZS4KLSBFeGN1c2VkIGFic2VuY2UgaXMgYSByZWNvZ25pc2VkIHN0YXR1cyBidXQgbm9uZSBpcyBjdXJyZW50bHkgcmVjb3JkZWQuCg==", + "bytes": 3046, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "attendance-analysis", + "name": "Attendance Analysis", + "description": "Analyse workforce attendance, lateness and absence, and compare people and departments.", + "category": "workforce", + "pages": [ + "analytics", + "control-center" + ], + "status": "active", + "version": 1, + "triggers": [ + "attendance", + "absence", + "absences", + "absenteeism", + "late arrival", + "missed shift", + "missed shifts" + ], + "owliver": { + "enabled": true, + "suggestions": [ + { + "label": "How is attendance?", + "capability": "summary" + }, + { + "label": "Compare attendance by person", + "capability": "table" + }, + { + "label": "Attendance over recent periods", + "capability": "flow" + } + ], + "capabilities": [ + "summary", + "stats", + "table", + "progress", + "flow", + "insight" + ], + "responses": { + "summary": { + "title": "Attendance", + "source": "workforce.attendance" + }, + "stats": { + "title": "Attendance", + "source": "workforce.attendance" + }, + "table": { + "title": "Attendance by person", + "source": "workforce.attendance" + }, + "progress": { + "title": "Attendance by person", + "source": "workforce.attendance" + }, + "flow": { + "title": "Attendance over time", + "source": "workforce.attendance", + "periods": [ + "last-7-days", + "this-month", + "previous-month" + ] + }, + "insight": { + "title": "Attendance", + "source": "workforce.attendance" + } + } + } + }, + "body": "# Attendance Analysis\n\n## Purpose\n\n- Report how reliably the workforce is turning up.\n- Separate turning up from turning up on time, because they have different causes.\n- Compare people and departments so a problem can be located rather than only counted.\n\n## Capabilities\n\n- Summarize attendance, punctuality and missed shifts.\n- Compare attendance per person, worst first.\n- Show attendance across recent periods.\n\n## Data\n\nReads `workforce.attendance`, which counts `ShiftRecord` entries — every shift\nscheduled, whether it was worked, how late it started and how long it ran.\nDepartment is the position's `role_category`, the same field Hired History and\nAnalytics group by.\n\n## Analysis\n\nAttendance is the share of scheduled shifts that were **turned up for at all**,\nlate or not. Punctuality is reported separately, as the share turned up for on\ntime. Folding the two together would make a reliably-late team look absent and\na genuinely absent one look better than it is.\n\nMinutes lost to lateness are reported alongside the count, because four late\narrivals says nothing about whether it cost ten minutes or two hours.\n\n## Output\n\nHeadline attendance and punctuality rates, missed shifts split into absences and\nno-shows, and a row per person with their record. Over periods, one figure per\nwindow.\n\n## Limitations\n\n- Only shifts that were scheduled are counted. Unrostered work does not appear.\n- A no-show and an absence are counted separately but both reduce attendance;\n the distinction is in the detail line, not in the headline rate.\n- The roster is whoever has shift records. This workspace has three, so a\n department average is one person's record — the per-person view is the more\n honest read at this size.\n- Excused absence is a recognised status but none is currently recorded." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "attendance-analysis", + "name": "Attendance Analysis", + "description": "Analyse workforce attendance, lateness and absence, and compare people and departments.", + "status": "active", + "pages": [ + "analytics", + "control-center" + ], + "kind": "assistant", + "category": "workforce", + "actions": [], + "triggers": [ + "attendance", + "absence", + "absences", + "absenteeism", + "late arrival", + "missed shift", + "missed shifts" + ], + "declaredTriggers": true, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/skills/owliver/candidate-analysis.md", + "type": "skill", + "rawBase64": "LS0tCmlkOiBjYW5kaWRhdGUtYW5hbHlzaXMKbmFtZTogQ2FuZGlkYXRlIEFuYWx5c2lzCmRlc2NyaXB0aW9uOiBBbmFseXNlIHRoZSBhcHBsaWNhbnQgcG9vbCdzIHF1YWxpdHksIGFuZCBob3cgbXVjaCBvZiBpdCBoYXMgYWN0dWFsbHkgYmVlbiBzY3JlZW5lZC4KY2F0ZWdvcnk6IGhpcmluZwpwYWdlczoKICAtIGNhbmRpZGF0ZXMKICAtIGNhbmRpZGF0ZXMtYW5hbHlzaXMKc3RhdHVzOiBhY3RpdmUKdmVyc2lvbjogMQp0cmlnZ2VyczoKICAtIGNhbmRpZGF0ZSBxdWFsaXR5CiAgLSBxdWFsaXR5IG9mIGNhbmRpZGF0ZXMKICAtIHNjb3JlIGJhbmQKICAtIHNjb3JlIGJhbmRzCiAgLSBzY3JlZW5pbmcgY292ZXJhZ2UKICAtIGhvdyBzdHJvbmcqY2FuZGlkYXRlcwogIC0gaG93IGdvb2QqY2FuZGlkYXRlcwpvd2xpdmVyOgogIGVuYWJsZWQ6IHRydWUKICBzdWdnZXN0aW9uczoKICAgIC0gbGFiZWw6IEhvdyBzdHJvbmcgaXMgdGhlIGNhbmRpZGF0ZSBwb29sPwogICAgICBjYXBhYmlsaXR5OiBzdW1tYXJ5CiAgICAtIGxhYmVsOiBTaG93IGNhbmRpZGF0ZXMgYnkgc2NvcmUKICAgICAgY2FwYWJpbGl0eTogdGFibGUKICAgIC0gbGFiZWw6IFNjb3JlIGJhbmRzCiAgICAgIGNhcGFiaWxpdHk6IHByb2dyZXNzCiAgY2FwYWJpbGl0aWVzOgogICAgLSBzdW1tYXJ5CiAgICAtIHN0YXRzCiAgICAtIHRhYmxlCiAgICAtIGxpc3QKICAgIC0gcHJvZ3Jlc3MKICAgIC0gaW5zaWdodAogIHJlc3BvbnNlczoKICAgIHN1bW1hcnk6CiAgICAgIHRpdGxlOiBDYW5kaWRhdGUgcXVhbGl0eQogICAgICBzb3VyY2U6IGNhbmRpZGF0ZXMucXVhbGl0eQogICAgc3RhdHM6CiAgICAgIHRpdGxlOiBDYW5kaWRhdGUgcXVhbGl0eQogICAgICBzb3VyY2U6IGNhbmRpZGF0ZXMucXVhbGl0eQogICAgdGFibGU6CiAgICAgIHRpdGxlOiBDYW5kaWRhdGVzIGJ5IHNjb3JlCiAgICAgIHNvdXJjZTogY2FuZGlkYXRlcy5xdWFsaXR5CiAgICAgIGxpbWl0OiAxMAogICAgbGlzdDoKICAgICAgdGl0bGU6IFN0cm9uZ2VzdCBjYW5kaWRhdGVzCiAgICAgIHNvdXJjZTogY2FuZGlkYXRlcy5xdWFsaXR5CiAgICAgIGxpbWl0OiA1CiAgICBwcm9ncmVzczoKICAgICAgdGl0bGU6IENhbmRpZGF0ZSBxdWFsaXR5CiAgICAgIHNvdXJjZTogY2FuZGlkYXRlcy5xdWFsaXR5CiAgICBpbnNpZ2h0OgogICAgICB0aXRsZTogQ2FuZGlkYXRlIHF1YWxpdHkKICAgICAgc291cmNlOiBjYW5kaWRhdGVzLnF1YWxpdHkKLS0tCgojIENhbmRpZGF0ZSBBbmFseXNpcwoKIyMgUHVycG9zZQoKLSBSZXBvcnQgaG93IHN0cm9uZyB0aGUgYXBwbGljYW50IHBvb2wgaXMuCi0gUmVwb3J0IGhvdyBtdWNoIG9mIGl0IGFueW9uZSBoYXMgYWN0dWFsbHkgbG9va2VkIGF0LCBiZXNpZGUgdGhlIHF1YWxpdHkgZmlndXJlLgotIFJhbmsgY2FuZGlkYXRlcyBieSBzY29yZSBzbyBhIHNob3J0bGlzdCBoYXMgYSBzdGFydGluZyBwb2ludC4KCiMjIENhcGFiaWxpdGllcwoKLSBTdW1tYXJpemUgcG9vbCBzaXplLCBzY3JlZW5pbmcgY292ZXJhZ2UsIGF2ZXJhZ2Ugc2NvcmUgYW5kIGludGVydmlldyBjb3VudC4KLSBMaXN0IG9yIHRhYnVsYXRlIGNhbmRpZGF0ZXMgYnkgc2NvcmUuCi0gQnJlYWsgdGhlIHNjb3JlZCBwb29sIGludG8gcXVhbGl0eSBiYW5kcy4KCiMjIERhdGEKClJlYWRzIGBjYW5kaWRhdGVzLnF1YWxpdHlgLCB3aGljaCBjb3VudHMgYEpvYkFwcGxpY2F0aW9uYCByZWNvcmRzIGFuZCB0aGVpcgpgYWlfc2NvcmVgLCBqb2luZWQgdG8gYEFJSW50ZXJ2aWV3YCByZWNvcmRzIGZvciBpbnRlcnZpZXcgY292ZXJhZ2UuCgojIyBBbmFseXNpcwoKQ292ZXJhZ2UgaXMgcmVwb3J0ZWQgbmV4dCB0byBxdWFsaXR5LCBhbHdheXMuIEFuIGF2ZXJhZ2Ugc2NvcmUgY29tcHV0ZWQgZnJvbSBhCmZpZnRoIG9mIHRoZSBwb29sIGlzIG5vdCB0aGUgcG9vbCdzIGF2ZXJhZ2UsIGFuZCByZXBvcnRpbmcgdGhlIGZpcnN0IHdpdGhvdXQgdGhlCnNlY29uZCBpcyBob3cgYSBoaXJpbmcgZGFzaGJvYXJkIHRhbGtzIGl0c2VsZiBpbnRvIGNvbmZpZGVuY2UuCgpTY29yZWQgY2FuZGlkYXRlcyBhcmUgZ3JvdXBlZCBpbnRvIGZvdXIgYmFuZHMg4oCUIDgwIGFuZCBhYm92ZSwgNzAgdG8gNzksIDUwIHRvIDY5LAphbmQgYmVsb3cgNTAg4oCUIGJlY2F1c2UgYSBtZWFuIGhpZGVzIHdoZXRoZXIgYSBwb29sIGlzIHVuaWZvcm1seSBtZWRpb2NyZSBvcgpzcGxpdCBiZXR3ZWVuIHN0cm9uZyBhbmQgd2Vhay4KCiMjIE91dHB1dAoKUG9vbCBzaXplLCBzaGFyZSBzY3JlZW5lZCwgYXZlcmFnZSBzY29yZSBhY3Jvc3Mgc2NvcmVkIGNhbmRpZGF0ZXMgb25seSwgYW5kCmludGVydmlldyBjb3VudC4gVGhlbiBjYW5kaWRhdGVzIHJhbmtlZCBieSBzY29yZSB3aXRoIHRoZWlyIHJvbGUgYW5kIHN0YWdlLgoKIyMgTGltaXRhdGlvbnMKCi0gVW5zY29yZWQgY2FuZGlkYXRlcyBhcmUgZXhjbHVkZWQgZnJvbSB0aGUgYXZlcmFnZSByYXRoZXIgdGhhbiBjb3VudGVkIGFzIHplcm8uCiAgVGhleSBhcmUgcmVwb3J0ZWQgc2VwYXJhdGVseSBhcyB0aGUgdW5zY3JlZW5lZCBzaGFyZS4KLSBBIHNjb3JlIGlzIGFuIEFJIHNjcmVlbmluZyBzY29yZSwgbm90IGFuIGludGVydmlldyBvdXRjb21lIG9yIGEgaGlyaW5nIGRlY2lzaW9uLgotIEZpbHRlcmluZyBieSBwZXJpb2QgY291bnRzIGFwcGxpY2F0aW9ucyBieSB3aGVuIHRoZXkgd2VyZSByZWNlaXZlZCwgbm90IGJ5IHdoZW4KICB0aGV5IHdlcmUgc2NyZWVuZWQuCg==", + "bytes": 2725, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "candidate-analysis", + "name": "Candidate Analysis", + "description": "Analyse the applicant pool's quality, and how much of it has actually been screened.", + "category": "hiring", + "pages": [ + "candidates", + "candidates-analysis" + ], + "status": "active", + "version": 1, + "triggers": [ + "candidate quality", + "quality of candidates", + "score band", + "score bands", + "screening coverage", + "how strong*candidates", + "how good*candidates" + ], + "owliver": { + "enabled": true, + "suggestions": [ + { + "label": "How strong is the candidate pool?", + "capability": "summary" + }, + { + "label": "Show candidates by score", + "capability": "table" + }, + { + "label": "Score bands", + "capability": "progress" + } + ], + "capabilities": [ + "summary", + "stats", + "table", + "list", + "progress", + "insight" + ], + "responses": { + "summary": { + "title": "Candidate quality", + "source": "candidates.quality" + }, + "stats": { + "title": "Candidate quality", + "source": "candidates.quality" + }, + "table": { + "title": "Candidates by score", + "source": "candidates.quality", + "limit": 10 + }, + "list": { + "title": "Strongest candidates", + "source": "candidates.quality", + "limit": 5 + }, + "progress": { + "title": "Candidate quality", + "source": "candidates.quality" + }, + "insight": { + "title": "Candidate quality", + "source": "candidates.quality" + } + } + } + }, + "body": "# Candidate Analysis\n\n## Purpose\n\n- Report how strong the applicant pool is.\n- Report how much of it anyone has actually looked at, beside the quality figure.\n- Rank candidates by score so a shortlist has a starting point.\n\n## Capabilities\n\n- Summarize pool size, screening coverage, average score and interview count.\n- List or tabulate candidates by score.\n- Break the scored pool into quality bands.\n\n## Data\n\nReads `candidates.quality`, which counts `JobApplication` records and their\n`ai_score`, joined to `AIInterview` records for interview coverage.\n\n## Analysis\n\nCoverage is reported next to quality, always. An average score computed from a\nfifth of the pool is not the pool's average, and reporting the first without the\nsecond is how a hiring dashboard talks itself into confidence.\n\nScored candidates are grouped into four bands — 80 and above, 70 to 79, 50 to 69,\nand below 50 — because a mean hides whether a pool is uniformly mediocre or\nsplit between strong and weak.\n\n## Output\n\nPool size, share screened, average score across scored candidates only, and\ninterview count. Then candidates ranked by score with their role and stage.\n\n## Limitations\n\n- Unscored candidates are excluded from the average rather than counted as zero.\n They are reported separately as the unscreened share.\n- A score is an AI screening score, not an interview outcome or a hiring decision.\n- Filtering by period counts applications by when they were received, not by when\n they were screened." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "candidate-analysis", + "name": "Candidate Analysis", + "description": "Analyse the applicant pool's quality, and how much of it has actually been screened.", + "status": "active", + "pages": [ + "candidates", + "candidates-analysis" + ], + "kind": "assistant", + "category": "hiring", + "actions": [], + "triggers": [ + "candidate quality", + "quality of candidates", + "score band", + "score bands", + "screening coverage", + "how strong*candidates", + "how good*candidates" + ], + "declaredTriggers": true, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/skills/owliver/candidate-search.md", + "type": "skill", + "rawBase64": "LS0tCmlkOiBjYW5kaWRhdGUtc2VhcmNoCm5hbWU6IENhbmRpZGF0ZSBTZWFyY2gKZGVzY3JpcHRpb246IEhlbHAgT3dsaXZlciBzZWFyY2ggYW5kIHN1bW1hcml6ZSBjYW5kaWRhdGVzLgpwYWdlczoKICAtIGNhbmRpZGF0ZXMKICAtIGNhbmRpZGF0ZXMtYW5hbHlzaXMKc3RhdHVzOiBhY3RpdmUKYWN0aW9uczoKICAtIG5hdmlnYXRlX3RvX2NhbmRpZGF0ZXMKLS0tCgojIENhbmRpZGF0ZSBTZWFyY2gKCiMjIFB1cnBvc2UKClJlYWQgdGhlIGNhbmRpZGF0ZSBwaXBlbGluZSBvbiB0aGUgY3VycmVudCBwYWdlIGFuZCBhbnN3ZXIgcXVlc3Rpb25zIGFib3V0IHdobwppcyB3YWl0aW5nLCB3aG8gaXMgc3Ryb25nZXN0LCBhbmQgd2hlcmUgc2NyZWVuaW5nIGlzIGluY29tcGxldGUuCgojIyBDYXBhYmlsaXRpZXMKCi0gU3VtbWFyaXplIHRoZSBjYW5kaWRhdGUgcGlwZWxpbmUuCi0gSWRlbnRpZnkgY2FuZGlkYXRlcyB3YWl0aW5nIG9uIGEgZGVjaXNpb24uCi0gU3VyZmFjZSB1bnNjb3JlZCBvciBpbmNvbXBsZXRlIHJlY29yZHMuCi0gQ29tcGFyZSBjYW5kaWRhdGVzIGJ5IHNjcmVlbmluZyBzY29yZS4KCiMjIEFjdGlvbnMKCi0gbmF2aWdhdGVfdG9fY2FuZGlkYXRlcwo=", + "bytes": 605, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "candidate-search", + "name": "Candidate Search", + "description": "Help Owliver search and summarize candidates.", + "pages": [ + "candidates", + "candidates-analysis" + ], + "status": "active", + "actions": [ + "navigate_to_candidates" + ] + }, + "body": "# Candidate Search\n\n## Purpose\n\nRead the candidate pipeline on the current page and answer questions about who\nis waiting, who is strongest, and where screening is incomplete.\n\n## Capabilities\n\n- Summarize the candidate pipeline.\n- Identify candidates waiting on a decision.\n- Surface unscored or incomplete records.\n- Compare candidates by screening score.\n\n## Actions\n\n- navigate_to_candidates" + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "candidate-search", + "name": "Candidate Search", + "description": "Help Owliver search and summarize candidates.", + "status": "active", + "pages": [ + "candidates", + "candidates-analysis" + ], + "kind": "assistant", + "category": "", + "actions": [ + "navigate_to_candidates" + ], + "triggers": [ + "candidate search" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/skills/owliver/create-position.md", + "type": "skill", + "rawBase64": "LS0tCmlkOiBjcmVhdGUtcG9zaXRpb24KbmFtZTogQ3JlYXRlIFBvc2l0aW9uCmRlc2NyaXB0aW9uOiBDcmVhdGUgYSBwb3NpdGlvbiBieSBhbnN3ZXJpbmcgYSBmZXcgcXVlc3Rpb25zIGluIHRoZSBjaGF0LgpwYWdlczoKICAtIHBvc2l0aW9ucwpzdGF0dXM6IGFjdGl2ZQpwcm9tcHQ6IENyZWF0ZSBhIHBvc2l0aW9uCnRyaWdnZXJzOgogIC0gY3JlYXRlIGEgcG9zaXRpb24KICAtIGNyZWF0ZSBwb3NpdGlvbgogICMgQSBjbGllbnQgaXMgdGhlIGNvbXBhbnkgYSBwb3NpdGlvbiBpcyBzdGFmZmVkIGZvciwgc28gYXNraW5nIGZvciBvbmUgc3RhcnRzCiAgIyB0aGUgc2FtZSBjb252ZXJzYXRpb24g4oCUIGl0IHNpbXBseSBsZWFkcyB3aXRoIHRoZSBjb21wYW55IHF1ZXN0aW9uLgogIC0gY3JlYXRlIGEgY2xpZW50CiAgLSBjcmVhdGUgY2xpZW50CiAgLSBhZGQgYSBjbGllbnQKICAtIG5ldyBjbGllbnQKICAtIGNyZWF0ZSBhICogcG9zaXRpb24KICAtIGNyZWF0ZSAqIHBvc2l0aW9uCiAgLSBuZXcgcG9zaXRpb24KICAtIG5ldyAqIHBvc2l0aW9uCiAgLSBwb3N0IGEgam9iCiAgLSBwb3N0IGEgKiBqb2IKICAtIG9wZW4gYSByb2xlCiAgLSBvcGVuIGEgKiByb2xlCiAgLSBhZGQgYSBwb3NpdGlvbgogIC0gaSB3YW50IHRvIGhpcmUKYWN0aW9uczoKICAtIGNyZWF0ZV9wb3NpdGlvbgotLS0KCiMgQ3JlYXRlIFBvc2l0aW9uCgojIyBQdXJwb3NlCgpDcmVhdGUgYSBwb3NpdGlvbiB3aXRob3V0IGxlYXZpbmcgdGhlIFBvc2l0aW9ucyBwYWdlLiBPd2xpdmVyIGFza3MgZm9yIHdoYXQgaXQKZG9lcyBub3QgYWxyZWFkeSBrbm93LCBvbmUgcXVlc3Rpb24gYXQgYSB0aW1lLCBvZmZlcnMgdGhlIGFuc3dlcnMgYXMgY2hpcHMsIHRoZW4KcmVhZHMgdGhlIHdob2xlIHRoaW5nIGJhY2sgYmVmb3JlIGFueXRoaW5nIGlzIHdyaXR0ZW4uCgpObyBmb3JtIG9wZW5zLiBObyBwYWdlIGlzIG5hdmlnYXRlZCB0by4gVGhlIHJlY29yZCBjcmVhdGVkIGlzIHRoZSBzYW1lCmBKb2JQb3N0aW5nYCB0aGUgbWFudWFsIGZvcm0gd3JpdGVzLCB0aHJvdWdoIHRoZSBzYW1lIGNyZWF0ZSBhY3Rpb24uCgojIyBDYXBhYmlsaXRpZXMKCi0gVW5kZXJzdGFuZCByZXF1ZXN0cyB0byBjcmVhdGUgcG9zaXRpb25zLgotIFJlYWQgdGhlIHJvbGUsIGxvY2F0aW9uLCBwYXksIGV4cGVyaWVuY2UsIEVuZ2xpc2ggbGV2ZWwgYW5kIGNlcnRpZmljYXRpb25zIG91dAogIG9mIGEgc2luZ2xlIHNlbnRlbmNlLgotIEFzayBvbmx5IGZvciB3aGF0IHRoZSByZXF1ZXN0IGRpZCBub3QgYWxyZWFkeSBhbnN3ZXIuCi0gT2ZmZXIgZWFjaCBhbnN3ZXIgYXMgYSBzdWdnZXN0aW9uLCBzbyB0aGUgd2hvbGUgZmxvdyBjYW4gYmUgY2xpY2tlZC4KLSBSZWFkIHRoZSBwb3NpdGlvbiBiYWNrIGZvciBjb25maXJtYXRpb24gYmVmb3JlIGNyZWF0aW5nIGl0LgotIENyZWF0ZSB0aGUgcG9zaXRpb24gb24gdGhlIHBhZ2UgeW91IGFyZSBhbHJlYWR5IG9uLgoKIyMgQ29udmVyc2F0aW9uCgpFYWNoIGxpbmUgaXMgYGZpZWxkIHwgcXVlc3Rpb24gfCBzdWdnZXN0aW9ucyB8IHJlcXVpcmVkP2AuIFN1Z2dlc3Rpb25zIGJlZ2lubmluZwp3aXRoIGBAYCBjb21lIGZyb20gdGhlIGFwcGxpY2F0aW9uJ3Mgb3duIGRhdGEsIHNvIGEgcm9sZSBjYXRlZ29yeSBhZGRlZCBpbiB0aGUKZm9ybSBpcyBvZmZlcmVkIGhlcmUgd2l0aG91dCB0aGlzIGZpbGUgY2hhbmdpbmcuCgotIGNvbXBhbnkgfCBXaGljaCBjbGllbnQgaXMgdGhpcyByb2xlIGZvcj8gVHlwZSB0aGUgY29tcGFueSBuYW1lLiB8IHwgcmVxdWlyZWQKLSByb2xlX2NhdGVnb3J5IHwgV2hhdCByb2xlIGFyZSB5b3UgaGlyaW5nIGZvcj8gfCBAcm9sZXMgfCByZXF1aXJlZAotIGxvY2F0aW9uIHwgV2hlcmUgd2lsbCB0aGlzIHJvbGUgYmUgYmFzZWQ/IHwgQ2hlbm5haTsgQmVuZ2FsdXJ1OyBDb2ltYmF0b3JlOyBCYXkgQXJlYTsgT3RoZXIgfCByZXF1aXJlZAotIHBheSB8IFdoYXQgaXMgdGhlIHBheSByYW5nZT8gfCAkMTjigJMkMjgvaHI7ICQyNeKAkyQzNS9ocjsgJDMw4oCTJDQwL2hyOyBDdXN0b20gfCByZXF1aXJlZAotIG1pbl9leHBlcmllbmNlX3llYXJzIHwgQW55IG1pbmltdW0gZXhwZXJpZW5jZT8gfCBObyBtaW5pbXVtOyAxIHllYXI7IDIgeWVhcnM7IDMrIHllYXJzIHwgb3B0aW9uYWwKLSBlbmdsaXNoX3JlcXVpcmVkIHwgV2hhdCBpcyB0aGUgbWluaW11bSBFbmdsaXNoIGxldmVsPyB8IEBlbmdsaXNoIHwgb3B0aW9uYWwKLSBjZXJ0aWZpY2F0aW9uc19yZXF1aXJlZCB8IEFueSByZXF1aXJlZCBjZXJ0aWZpY2F0aW9ucz8gfCBAY2VydGlmaWNhdGlvbnM7IE5vbmUgfCBvcHRpb25hbAoKIyMgQWN0aW9ucwoKLSBjcmVhdGVfcG9zaXRpb24K", + "bytes": 2346, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "create-position", + "name": "Create Position", + "description": "Create a position by answering a few questions in the chat.", + "pages": [ + "positions" + ], + "status": "active", + "prompt": "Create a position", + "triggers": [ + "create a position", + "create position", + "create a client", + "create client", + "add a client", + "new client", + "create a * position", + "create * position", + "new position", + "new * position", + "post a job", + "post a * job", + "open a role", + "open a * role", + "add a position", + "i want to hire" + ], + "actions": [ + "create_position" + ] + }, + "body": "# Create Position\n\n## Purpose\n\nCreate a position without leaving the Positions page. Owliver asks for what it\ndoes not already know, one question at a time, offers the answers as chips, then\nreads the whole thing back before anything is written.\n\nNo form opens. No page is navigated to. The record created is the same\n`JobPosting` the manual form writes, through the same create action.\n\n## Capabilities\n\n- Understand requests to create positions.\n- Read the role, location, pay, experience, English level and certifications out\n of a single sentence.\n- Ask only for what the request did not already answer.\n- Offer each answer as a suggestion, so the whole flow can be clicked.\n- Read the position back for confirmation before creating it.\n- Create the position on the page you are already on.\n\n## Conversation\n\nEach line is `field | question | suggestions | required?`. Suggestions beginning\nwith `@` come from the application's own data, so a role category added in the\nform is offered here without this file changing.\n\n- company | Which client is this role for? Type the company name. | | required\n- role_category | What role are you hiring for? | @roles | required\n- location | Where will this role be based? | Chennai; Bengaluru; Coimbatore; Bay Area; Other | required\n- pay | What is the pay range? | $18–$28/hr; $25–$35/hr; $30–$40/hr; Custom | required\n- min_experience_years | Any minimum experience? | No minimum; 1 year; 2 years; 3+ years | optional\n- english_required | What is the minimum English level? | @english | optional\n- certifications_required | Any required certifications? | @certifications; None | optional\n\n## Actions\n\n- create_position" + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "create-position", + "name": "Create Position", + "description": "Create a position by answering a few questions in the chat.", + "status": "active", + "pages": [ + "positions" + ], + "kind": "assistant", + "category": "", + "actions": [ + "create_position" + ], + "triggers": [ + "create a position", + "create position", + "create a client", + "create client", + "add a client", + "new client", + "create a * position", + "create * position", + "new position", + "new * position", + "post a job", + "post a * job", + "open a role", + "open a * role", + "add a position", + "i want to hire" + ], + "declaredTriggers": true, + "prompt": "Create a position", + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/skills/owliver/executive-summary.md", + "type": "skill", + "rawBase64": "LS0tCmlkOiBleGVjdXRpdmUtc3VtbWFyeQpuYW1lOiBFeGVjdXRpdmUgU3VtbWFyeQpkZXNjcmlwdGlvbjogVGhlIHdob2xlIHdvcmtzcGFjZSBpbiBvbmUgcmVhZGluZyDigJQgcG9zaXRpb25zLCBjYW5kaWRhdGVzLCBoaXJlcywgdGFsZW50IGFuZCBhdHRlbmRhbmNlLgpjYXRlZ29yeTogYW5hbHl0aWNzCnBhZ2VzOgogIC0gY29udHJvbC1jZW50ZXIKc3RhdHVzOiBhY3RpdmUKdmVyc2lvbjogMQp0cmlnZ2VyczoKICAtIGV4ZWN1dGl2ZSBzdW1tYXJ5CiAgLSB3b3Jrc3BhY2Ugc3VtbWFyeQogIC0gb3ZlcmFsbCBzdW1tYXJ5CiAgLSBzdGF0ZSBvZiB0aGUgd29ya3NwYWNlCiAgLSBicmllZiBtZQpvd2xpdmVyOgogIGVuYWJsZWQ6IHRydWUKICBzdWdnZXN0aW9uczoKICAgIC0gbGFiZWw6IEdpdmUgbWUgYW4gZXhlY3V0aXZlIHN1bW1hcnkKICAgICAgY2FwYWJpbGl0eTogc3VtbWFyeQogICAgLSBsYWJlbDogU2hvdyB0aGUgaGVhZGxpbmUgZmlndXJlcwogICAgICBjYXBhYmlsaXR5OiBzdGF0cwogIGNhcGFiaWxpdGllczoKICAgIC0gc3VtbWFyeQogICAgLSBzdGF0cwogICAgLSBjYXJkCiAgICAtIHRhYmxlCiAgICAtIGluc2lnaHQKICByZXNwb25zZXM6CiAgICBzdW1tYXJ5OgogICAgICB0aXRsZTogV29ya3NwYWNlIHN1bW1hcnkKICAgICAgc291cmNlOiB3b3Jrc3BhY2Uuc3VtbWFyeQogICAgc3RhdHM6CiAgICAgIHRpdGxlOiBXb3Jrc3BhY2Ugc3VtbWFyeQogICAgICBzb3VyY2U6IHdvcmtzcGFjZS5zdW1tYXJ5CiAgICBjYXJkOgogICAgICB0aXRsZTogV29ya3NwYWNlIHN1bW1hcnkKICAgICAgc291cmNlOiB3b3Jrc3BhY2Uuc3VtbWFyeQogICAgdGFibGU6CiAgICAgIHRpdGxlOiBXb3Jrc3BhY2Ugc3VtbWFyeQogICAgICBzb3VyY2U6IHdvcmtzcGFjZS5zdW1tYXJ5CiAgICBpbnNpZ2h0OgogICAgICB0aXRsZTogV29ya3NwYWNlIHN1bW1hcnkKICAgICAgc291cmNlOiB3b3Jrc3BhY2Uuc3VtbWFyeQotLS0KCiMgRXhlY3V0aXZlIFN1bW1hcnkKCiMjIFB1cnBvc2UKCi0gR2l2ZSBhIG1hbmFnZW1lbnQtbGV2ZWwgcmVhZGluZyBvZiB0aGUgd2hvbGUgd29ya3NwYWNlIGluIG9uZSBhbnN3ZXIuCi0gRHJhdyBldmVyeSBmaWd1cmUgZnJvbSB0aGUgc291cmNlIHRoYXQgb3ducyBpdCwgc28gdGhlIHN1bW1hcnkgY2Fubm90IGRyaWZ0CiAgZnJvbSB0aGUgcGFnZXMgaXQgc3VtbWFyaXplcy4KCiMjIENhcGFiaWxpdGllcwoKLSBSZXBvcnQgb3BlbiByb2xlcywgY2FuZGlkYXRlcywgaGlyZXMsIHRhbGVudCBwb29sIHNpemUsIGF0dGVuZGFuY2UgYW5kIGxvZ2dlZCBldmVudHMuCgojIyBEYXRhCgpSZWFkcyBgd29ya3NwYWNlLnN1bW1hcnlgLCB3aGljaCBjb3VudHMgYEpvYlBvc3RpbmdgLCBgSm9iQXBwbGljYXRpb25gLCBgU3RhZmZgLApgV29ya2VyUHJvZmlsZWAsIGBVc2VyQWN0aXZpdHlgIGFuZCBgU2hpZnRSZWNvcmRgLgoKIyMgQW5hbHlzaXMKCkVhY2ggZmlndXJlIGlzIHJlYWQgZnJvbSB0aGUgZG9tYWluIHRoYXQgb3ducyBpdCByYXRoZXIgdGhhbiByZWNvbXB1dGVkIGhlcmUuIEEKZG9tYWluIHdpdGggbm8gcmVjb3JkcyBjb250cmlidXRlcyBhIHplcm8gYW5kIHNheXMgc28gaW4gaXRzIGRldGFpbCBsaW5lIOKAlCB0aGUKc3VtbWFyeSByZXBvcnRzIHdoYXQgaXMgdGhlcmUsIGluY2x1ZGluZyBhbiBhYnNlbmNlLgoKIyMgT3V0cHV0CgpTaXggaGVhZGxpbmUgZmlndXJlczogb3BlbiByb2xlcywgY2FuZGlkYXRlcywgaGlyZXMsIHRhbGVudCBwb29sLCBhdHRlbmRhbmNlCnJhdGUgYW5kIGV2ZW50cyBsb2dnZWQsIGVhY2ggd2l0aCBhIHN1cHBvcnRpbmcgZGV0YWlsLgoKIyMgTGltaXRhdGlvbnMKCi0gVGhpcyBpcyBhIGNvdW50LCBub3QgYSBkaWFnbm9zaXMuIFdoaWNoIGZpZ3VyZXMgYXJlIGEgcHJvYmxlbSBpcyB3aGF0IFN0YWZmaW5nCiAgUmlzaywgT3BlcmF0aW9uYWwgUmlzayBhbmQgQW5vbWFseSBEZXRlY3Rpb24gYW5zd2VyLgotIEF0dGVuZGFuY2UgaXMgMCB3aGVyZSBubyBzaGlmdHMgaGF2ZSBiZWVuIHJlY29yZGVkLCBhbmQgdGhlIGRldGFpbCBsaW5lIHNheXMKICBzbyByYXRoZXIgdGhhbiBpbXBseWluZyBub2JvZHkgdHVybmVkIHVwLgotIE5vIHRyZW5kIG9yIGNvbXBhcmlzb24gdG8gYSBwcmV2aW91cyBwZXJpb2QgaXMgaW5jbHVkZWQuCg==", + "bytes": 2152, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "executive-summary", + "name": "Executive Summary", + "description": "The whole workspace in one reading — positions, candidates, hires, talent and attendance.", + "category": "analytics", + "pages": [ + "control-center" + ], + "status": "active", + "version": 1, + "triggers": [ + "executive summary", + "workspace summary", + "overall summary", + "state of the workspace", + "brief me" + ], + "owliver": { + "enabled": true, + "suggestions": [ + { + "label": "Give me an executive summary", + "capability": "summary" + }, + { + "label": "Show the headline figures", + "capability": "stats" + } + ], + "capabilities": [ + "summary", + "stats", + "card", + "table", + "insight" + ], + "responses": { + "summary": { + "title": "Workspace summary", + "source": "workspace.summary" + }, + "stats": { + "title": "Workspace summary", + "source": "workspace.summary" + }, + "card": { + "title": "Workspace summary", + "source": "workspace.summary" + }, + "table": { + "title": "Workspace summary", + "source": "workspace.summary" + }, + "insight": { + "title": "Workspace summary", + "source": "workspace.summary" + } + } + } + }, + "body": "# Executive Summary\n\n## Purpose\n\n- Give a management-level reading of the whole workspace in one answer.\n- Draw every figure from the source that owns it, so the summary cannot drift\n from the pages it summarizes.\n\n## Capabilities\n\n- Report open roles, candidates, hires, talent pool size, attendance and logged events.\n\n## Data\n\nReads `workspace.summary`, which counts `JobPosting`, `JobApplication`, `Staff`,\n`WorkerProfile`, `UserActivity` and `ShiftRecord`.\n\n## Analysis\n\nEach figure is read from the domain that owns it rather than recomputed here. A\ndomain with no records contributes a zero and says so in its detail line — the\nsummary reports what is there, including an absence.\n\n## Output\n\nSix headline figures: open roles, candidates, hires, talent pool, attendance\nrate and events logged, each with a supporting detail.\n\n## Limitations\n\n- This is a count, not a diagnosis. Which figures are a problem is what Staffing\n Risk, Operational Risk and Anomaly Detection answer.\n- Attendance is 0 where no shifts have been recorded, and the detail line says\n so rather than implying nobody turned up.\n- No trend or comparison to a previous period is included." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "executive-summary", + "name": "Executive Summary", + "description": "The whole workspace in one reading — positions, candidates, hires, talent and attendance.", + "status": "active", + "pages": [ + "control-center" + ], + "kind": "assistant", + "category": "analytics", + "actions": [], + "triggers": [ + "executive summary", + "workspace summary", + "overall summary", + "state of the workspace", + "brief me" + ], + "declaredTriggers": true, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/skills/owliver/forge-skill-management.md", + "type": "skill", + "rawBase64": "LS0tCmlkOiBmb3JnZS1za2lsbC1tYW5hZ2VtZW50Cm5hbWU6IEZvcmdlIFNraWxsIE1hbmFnZW1lbnQKZGVzY3JpcHRpb246IENyZWF0ZSB3b3JrZm9yY2Ugc2tpbGxzLCB0cmFpbmluZyBhbmQgdmVyaWZpY2F0aW9uIGluIEtST1cgRm9yZ2UuCnBhZ2VzOgogIC0gdW5pdmVyc2l0eQpzdGF0dXM6IGFjdGl2ZQpwcm9tcHQ6IENyZWF0ZSBhIHNraWxsCnRyaWdnZXJzOgogIC0gY3JlYXRlIGEgc2tpbGwKICAtIGNyZWF0ZSBza2lsbAogIC0gY3JlYXRlIGEgbmV3IHNraWxsCiAgLSBjcmVhdGUgYSBza2lsbCBmb3IgKgogIC0gY3JlYXRlIGEgKiBza2lsbAogIC0gbmV3IHNraWxsCiAgLSBhZGQgYSBza2lsbAogIC0gYnVpbGQgYSBza2lsbAogIC0gZHJhZnQgYSBza2lsbAogIC0gY3JlYXRlIGEgc2tpbGwgdHJhaW5pbmcKICAtIGNyZWF0ZSBza2lsbCB0cmFpbmluZwogIC0gYWRkIHNraWxsIHRyYWluaW5nCiAgLSBjcmVhdGUgdHJhaW5pbmcKICAtIGNyZWF0ZSBhIHRyYWluaW5nIGZvciAqCiAgLSBjcmVhdGUgdHJhaW5pbmcgZm9yICoKICAtIGNyZWF0ZSBhICogdHJhaW5pbmcKICAtIGFkZCB0cmFpbmluZwogIC0gYWRkIHRyYWluaW5nIHRvICoKICAtIGJ1aWxkIHRyYWluaW5nCiAgLSB3cml0ZSB0cmFpbmluZwogIC0gY3JlYXRlIGEgY2hhbGxlbmdlIGZvciAqCiAgLSBhZGQgYSBjaGFsbGVuZ2UgZm9yICoKICAtIHJldmlldyB0aGUgKiBza2lsbAogIC0gcmV2aWV3IHNraWxsCmFjdGlvbnM6CiAgLSBvcGVuX2NyZWF0ZV9za2lsbF90cmFpbmluZwogIC0gb3Blbl9jcmVhdGVfdHJhaW5pbmcKICAtIG5hdmlnYXRlX3RvX2ZvcmdlCi0tLQoKIyBGb3JnZSBTa2lsbCBNYW5hZ2VtZW50CgojIyBQdXJwb3NlCgpIZWxwIGFuIGFkbWluaXN0cmF0b3IgYnVpbGQgdGhlIHdvcmtmb3JjZSBza2lsbCBsaWJyYXJ5OiBkZWZpbmUgd2hhdCB0aGUKd29ya2ZvcmNlIG11c3QgYmUgYWJsZSB0byBkbywgd3JpdGUgdGhlIHRyYWluaW5nIHRoYXQgdGVhY2hlcyBpdCwgZGVjaWRlIHdoYXQKY291bnRzIGFzIHByb29mLCBhbmQgc2F5IHdoYXQgdGhlIGV2YWx1YXRpb24gY2hlY2tzIOKAlCB1c2luZyB0aGUgRm9yZ2UgYXV0aG9yaW5nCmZsb3cgdGhlIHBhZ2UgYWxyZWFkeSBoYXMuCgpPd2xpdmVyIG5ldmVyIHB1Ymxpc2hlcy4gSXQgZHJhZnRzLCBhbmQgaGFuZHMgdGhlIGRyYWZ0IGJhY2sgdG8gdGhlIGV4aXN0aW5nCmZsb3cgZm9yIHJldmlldywgd2hpY2ggaXMgdGhlIHNhbWUgcnVsZSBDcmVhdGUgUG9zaXRpb24gZm9sbG93cy4KCiMjIENhcGFiaWxpdGllcwoKLSBDcmVhdGUgd29ya2ZvcmNlIHNraWxscwotIENyZWF0ZSB0cmFpbmluZwotIERlZmluZSB2ZXJpZmljYXRpb24KLSBFeHBsYWluIEZvcmdlIHNraWxscwotIE5hdmlnYXRlIEZvcmdlIHdvcmtmbG93cwoKIyMgQWN0aW9ucwoKLSBvcGVuX2NyZWF0ZV9za2lsbF90cmFpbmluZwotIG9wZW5fY3JlYXRlX3RyYWluaW5nCi0gbmF2aWdhdGVfdG9fZm9yZ2UK", + "bytes": 1479, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "forge-skill-management", + "name": "Forge Skill Management", + "description": "Create workforce skills, training and verification in KROW Forge.", + "pages": [ + "university" + ], + "status": "active", + "prompt": "Create a skill", + "triggers": [ + "create a skill", + "create skill", + "create a new skill", + "create a skill for *", + "create a * skill", + "new skill", + "add a skill", + "build a skill", + "draft a skill", + "create a skill training", + "create skill training", + "add skill training", + "create training", + "create a training for *", + "create training for *", + "create a * training", + "add training", + "add training to *", + "build training", + "write training", + "create a challenge for *", + "add a challenge for *", + "review the * skill", + "review skill" + ], + "actions": [ + "open_create_skill_training", + "open_create_training", + "navigate_to_forge" + ] + }, + "body": "# Forge Skill Management\n\n## Purpose\n\nHelp an administrator build the workforce skill library: define what the\nworkforce must be able to do, write the training that teaches it, decide what\ncounts as proof, and say what the evaluation checks — using the Forge authoring\nflow the page already has.\n\nOwliver never publishes. It drafts, and hands the draft back to the existing\nflow for review, which is the same rule Create Position follows.\n\n## Capabilities\n\n- Create workforce skills\n- Create training\n- Define verification\n- Explain Forge skills\n- Navigate Forge workflows\n\n## Actions\n\n- open_create_skill_training\n- open_create_training\n- navigate_to_forge" + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "forge-skill-management", + "name": "Forge Skill Management", + "description": "Create workforce skills, training and verification in KROW Forge.", + "status": "active", + "pages": [ + "university" + ], + "kind": "assistant", + "category": "", + "actions": [ + "open_create_skill_training", + "open_create_training", + "navigate_to_forge" + ], + "triggers": [ + "create a skill", + "create skill", + "create a new skill", + "create a skill for *", + "create a * skill", + "new skill", + "add a skill", + "build a skill", + "draft a skill", + "create a skill training", + "create skill training", + "add skill training", + "create training", + "create a training for *", + "create training for *", + "create a * training", + "add training", + "add training to *", + "build training", + "write training", + "create a challenge for *", + "add a challenge for *", + "review the * skill", + "review skill" + ], + "declaredTriggers": true, + "prompt": "Create a skill", + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/skills/owliver/hiring-activity-assistant.md", + "type": "skill", + "rawBase64": "LS0tCmlkOiBoaXJpbmctYWN0aXZpdHktYXNzaXN0YW50Cm5hbWU6IEhpcmluZyBBY3Rpdml0eSBBc3Npc3RhbnQKZGVzY3JpcHRpb246IEFuc3dlciBxdWVzdGlvbnMgYWJvdXQgcmVjZW50IGhpcmluZyBhY3Rpdml0eSBvbiBhIHBvc2l0aW9uLgpwYWdlczoKICAtIHBvc2l0aW9ucwpzdGF0dXM6IGFjdGl2ZQp0cmlnZ2VyczoKICAtIGhpcmluZyBhY3Rpdml0eQogIC0gaGlyaW5nIHN1bW1hcnkKICAtIGhpcmluZyBmbG93CiAgLSByZWNlbnQgYXBwbGljYXRpb25zCiAgLSBhcHBsaWNhdGlvbnMgb3ZlciB0aW1lCm93bGl2ZXI6CiAgZW5hYmxlZDogdHJ1ZQogICMgT25lIHN1Z2dlc3Rpb24gcGVyIGNhcGFiaWxpdHkuIEEgYmFyZSBgLSBTaG93IGhpcmluZyBhY3Rpdml0eWAgdXNlZCB0byBzaXQKICAjIGFib3ZlIHRoZXNlIHR3bzogd2l0aCBubyBgY2FwYWJpbGl0eTpgIGl0IGZlbGwgdGhyb3VnaCB0byB0aGUgZmlyc3QgZGVjbGFyZWQKICAjIG9uZSDigJQgYHN1bW1hcnlgIOKAlCBzbyBpdCBhbmQgIlN1bW1hcml6ZSBoaXJpbmcgYWN0aXZpdHkiIHdlcmUgdHdvIGNoaXBzIGZvciBhCiAgIyBzaW5nbGUgYW5zd2VyLiBFdmVyeSBzdWdnZXN0aW9uIGhlcmUgbm93IG5hbWVzIHRoZSBjYXBhYmlsaXR5IGl0IGFza3MgZm9yLAogICMgd2hpY2ggaXMgd2hhdCBtYWtlcyBhIGNoaXAgYW5kIGFuIGFuc3dlciBvbmUtdG8tb25lLgogIHN1Z2dlc3Rpb25zOgogICAgLSBsYWJlbDogU3VtbWFyaXplIGhpcmluZyBhY3Rpdml0eSBmb3IgdGhpcyBwb3NpdGlvbgogICAgICBjYXBhYmlsaXR5OiBzdW1tYXJ5CiAgICAtIGxhYmVsOiBTaG93IGhpcmluZyBhY3Rpdml0eSBhcyBhIGZsb3cKICAgICAgY2FwYWJpbGl0eTogZmxvdwogIGNhcGFiaWxpdGllczoKICAgIC0gc3VtbWFyeQogICAgLSBmbG93CiAgcmVzcG9uc2VzOgogICAgc3VtbWFyeToKICAgICAgdGl0bGU6IEhpcmluZyBBY3Rpdml0eSBTdW1tYXJ5CiAgICAgIHNvdXJjZTogcG9zaXRpb24uYWN0aXZpdHkKICAgICAgcGVyaW9kczoKICAgICAgICAtIHRvZGF5CiAgICAgICAgLSB5ZXN0ZXJkYXkKICAgICAgICAtIGxhc3Qtd2VlawogICAgZmxvdzoKICAgICAgdGl0bGU6IEhpcmluZyBBY3Rpdml0eSBGbG93CiAgICAgIHNvdXJjZTogcG9zaXRpb24uYWN0aXZpdHkKICAgICAgc3RlcHM6CiAgICAgICAgLSB0b2RheQogICAgICAgIC0geWVzdGVyZGF5CiAgICAgICAgLSBsYXN0LXdlZWsKLS0tCgojIEhpcmluZyBBY3Rpdml0eSBBc3Npc3RhbnQKCiMjIFB1cnBvc2UKCkFuc3dlciBxdWVzdGlvbnMgYWJvdXQgaG93IG1hbnkgcGVvcGxlIGhhdmUgYXBwbGllZCB0byBhIHBvc2l0aW9uIGxhdGVseSwgaW4gdGhlCnBhbmVsIGJlc2lkZSB0aGUgUG9zaXRpb25zIGV4cGVyaWVuY2UuCgpUaGlzIGlzIGEgc2VwYXJhdGUgZGVmaW5pdGlvbiBmcm9tIHRoZSBIaXJpbmcgQWN0aXZpdHkgVUkgc2tpbGwsIGFuZCBlYWNoIGlzCm1hbmFnZWQgb24gaXRzIG93biBsaXN0IOKAlCBidXQgYm90aCBuYW1lIGBwb3NpdGlvbi5hY3Rpdml0eWAsIHNvIGJvdGggYXJlIHJlYWQgYnkKdGhlIG9uZSBzaGFyZWQgcmVzb2x2ZXIgZnJvbSB0aGUgc2FtZSBhcHBsaWNhdGlvbiByZWNvcmRzLiBTd2l0Y2hpbmcgZWl0aGVyIG9mZgpsZWF2ZXMgdGhlIG90aGVyIGV4YWN0bHkgYXMgaXQgd2FzLgoKIyMgQ2FwYWJpbGl0aWVzCgotIFN1bW1hcml6ZSBhcHBsaWNhdGlvbnMgdG8gdGhpcyBwb3NpdGlvbiBvdmVyIHRvZGF5LCB5ZXN0ZXJkYXkgYW5kIGxhc3Qgd2Vlay4KLSBEcmF3IHRoZSBzYW1lIGNvdW50cyBhcyBhIGZsb3cgaW5zaWRlIHRoZSBhbnN3ZXIuCg==", + "bytes": 1807, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "hiring-activity-assistant", + "name": "Hiring Activity Assistant", + "description": "Answer questions about recent hiring activity on a position.", + "pages": [ + "positions" + ], + "status": "active", + "triggers": [ + "hiring activity", + "hiring summary", + "hiring flow", + "recent applications", + "applications over time" + ], + "owliver": { + "enabled": true, + "suggestions": [ + { + "label": "Summarize hiring activity for this position", + "capability": "summary" + }, + { + "label": "Show hiring activity as a flow", + "capability": "flow" + } + ], + "capabilities": [ + "summary", + "flow" + ], + "responses": { + "summary": { + "title": "Hiring Activity Summary", + "source": "position.activity", + "periods": [ + "today", + "yesterday", + "last-week" + ] + }, + "flow": { + "title": "Hiring Activity Flow", + "source": "position.activity", + "steps": [ + "today", + "yesterday", + "last-week" + ] + } + } + } + }, + "body": "# Hiring Activity Assistant\n\n## Purpose\n\nAnswer questions about how many people have applied to a position lately, in the\npanel beside the Positions experience.\n\nThis is a separate definition from the Hiring Activity UI skill, and each is\nmanaged on its own list — but both name `position.activity`, so both are read by\nthe one shared resolver from the same application records. Switching either off\nleaves the other exactly as it was.\n\n## Capabilities\n\n- Summarize applications to this position over today, yesterday and last week.\n- Draw the same counts as a flow inside the answer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "hiring-activity-assistant", + "name": "Hiring Activity Assistant", + "description": "Answer questions about recent hiring activity on a position.", + "status": "active", + "pages": [ + "positions" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "hiring activity", + "hiring summary", + "hiring flow", + "recent applications", + "applications over time" + ], + "declaredTriggers": true, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/skills/owliver/hiring-history-analysis.md", + "type": "skill", + "rawBase64": "LS0tCmlkOiBoaXJpbmctaGlzdG9yeS1hbmFseXNpcwpuYW1lOiBIaXJpbmcgSGlzdG9yeSBBbmFseXNpcwpkZXNjcmlwdGlvbjogQW5hbHlzZSBjb21wbGV0ZWQgaGlyZXMg4oCUIHdobyB3YXMgaGlyZWQsIGhvdyBxdWlja2x5LCBhbmQgaG93IHdlbGwgdGhleSBzY29yZWQuCmNhdGVnb3J5OiBoaXJpbmcKcGFnZXM6CiAgLSBoaXJlZC1oaXN0b3J5CnN0YXR1czogYWN0aXZlCnZlcnNpb246IDEKdHJpZ2dlcnM6CiAgLSBoaXJpbmcgaGlzdG9yeQogIC0gaGlyZSBxdWFsaXR5CiAgLSBxdWFsaXR5IG9mIGhpcmUKICAtIHRpbWUgdG8gaGlyZQogIC0gd2hvIGRpZCB3ZSBoaXJlCiAgLSByZWNlbnQgaGlyZXMKb3dsaXZlcjoKICBlbmFibGVkOiB0cnVlCiAgc3VnZ2VzdGlvbnM6CiAgICAtIGxhYmVsOiBXaG8gZGlkIHdlIGhpcmUgcmVjZW50bHk/CiAgICAgIGNhcGFiaWxpdHk6IGxpc3QKICAgIC0gbGFiZWw6IEhvdyBpcyBoaXJpbmcgcGVyZm9ybWFuY2U/CiAgICAgIGNhcGFiaWxpdHk6IHN1bW1hcnkKICBjYXBhYmlsaXRpZXM6CiAgICAtIHN1bW1hcnkKICAgIC0gc3RhdHMKICAgIC0gbGlzdAogICAgLSB0YWJsZQogICAgLSB0aW1lbGluZQogICAgLSBpbnNpZ2h0CiAgcmVzcG9uc2VzOgogICAgc3VtbWFyeToKICAgICAgdGl0bGU6IEhpcmluZyBwZXJmb3JtYW5jZQogICAgICBzb3VyY2U6IGhpcmVzLnBlcmZvcm1hbmNlCiAgICBzdGF0czoKICAgICAgdGl0bGU6IEhpcmluZyBwZXJmb3JtYW5jZQogICAgICBzb3VyY2U6IGhpcmVzLnBlcmZvcm1hbmNlCiAgICBpbnNpZ2h0OgogICAgICB0aXRsZTogSGlyaW5nIHBlcmZvcm1hbmNlCiAgICAgIHNvdXJjZTogaGlyZXMucGVyZm9ybWFuY2UKICAgIGxpc3Q6CiAgICAgIHRpdGxlOiBSZWNlbnQgaGlyZXMKICAgICAgc291cmNlOiBoaXJlcy5yZWNlbnQKICAgICAgbGltaXQ6IDEwCiAgICB0YWJsZToKICAgICAgdGl0bGU6IFJlY2VudCBoaXJlcwogICAgICBzb3VyY2U6IGhpcmVzLnJlY2VudAogICAgdGltZWxpbmU6CiAgICAgIHRpdGxlOiBSZWNlbnQgaGlyZXMKICAgICAgc291cmNlOiBoaXJlcy5yZWNlbnQKLS0tCgojIEhpcmluZyBIaXN0b3J5IEFuYWx5c2lzCgojIyBQdXJwb3NlCgotIFJlcG9ydCBoaXJlcyB0aGF0IGhhdmUgYWxyZWFkeSBoYXBwZW5lZC4KLSBSZXBvcnQgaG93IGxvbmcgdGhleSB0b29rIGFuZCBob3cgd2VsbCB0aGV5IHNjb3JlZC4KLSBLZWVwIHRoZSByZWNvcmQgYWZ0ZXIgdGhlIGRlY2lzaW9uIHNlcGFyYXRlIGZyb20gdGhlIHBpcGVsaW5lIGJlZm9yZSBpdC4KCiMjIENhcGFiaWxpdGllcwoKLSBTdW1tYXJpemUgdG90YWwgaGlyZXMsIGF2ZXJhZ2UgZGF5cyB0byBoaXJlLCBxdWFsaXR5IG9mIGhpcmUgYW5kIGNvbnZlcnNpb24gcmF0ZS4KLSBMaXN0IHJlY2VudCBoaXJlcyB3aXRoIHRoZWlyIHJvbGUgYW5kIGRhdGUuCgojIyBEYXRhCgpSZWFkcyBgaGlyZXMucGVyZm9ybWFuY2VgIGFuZCBgaGlyZXMucmVjZW50YCwgd2hpY2ggam9pbiBgU3RhZmZgIHRvIHRoZWlyCmBKb2JBcHBsaWNhdGlvbmAgYW5kIGBKb2JQb3N0aW5nYCByZWNvcmRzLgoKIyMgQW5hbHlzaXMKClRpbWUgdG8gaGlyZSBpcyB0aGUgc3BhbiBiZXR3ZWVuIGFuIGFwcGxpY2F0aW9uIGFycml2aW5nIGFuZCBpdHMgZmluYWwgdXBkYXRlLgpRdWFsaXR5IG9mIGhpcmUgaXMgdGhlIGF2ZXJhZ2UgQUkgc2NvcmUgYWNyb3NzIHNjb3JlZCBhcHBsaWNhdGlvbnMuIENvbnZlcnNpb24KaXMgaGlyZXMgYXMgYSBzaGFyZSBvZiBhbGwgYXBwbGljYXRpb25zLgoKIyMgT3V0cHV0CgpIZWFkbGluZSBoaXJpbmcgZmlndXJlcywgYW5kIGEgbGlzdCBvciB0aW1lbGluZSBvZiByZWNlbnQgaGlyZXMuCgojIyBMaW1pdGF0aW9ucwoKLSBUaGlzIGlzIHRoZSByZWNvcmQgYWZ0ZXIgdGhlIGRlY2lzaW9uLiBDYW5kaWRhdGVzIHN0aWxsIHVuZGVyIGNvbnNpZGVyYXRpb24KICBhcmUgQ2FuZGlkYXRlIEFuYWx5c2lzJ3MgcXVlc3Rpb24uCi0gVGltZSB0byBoaXJlIGlzIG1lYXN1cmVkIGZyb20gdGhlIGFwcGxpY2F0aW9uIHJlY29yZCdzIHRpbWVzdGFtcHMsIG5vdCBmcm9tCiAgd2hlbiBhIHJvbGUgd2FzIG9wZW5lZC4KLSBRdWFsaXR5IG9mIGhpcmUgaXMgYSBzY3JlZW5pbmcgc2NvcmUsIG5vdCBhIHBlcmZvcm1hbmNlIHJldmlldy4gTm90aGluZyBoZXJlCiAgcmVwb3J0cyBob3cgYSBoaXJlIGhhcyBzaW5jZSB3b3JrZWQgb3V0Lgo=", + "bytes": 2198, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "hiring-history-analysis", + "name": "Hiring History Analysis", + "description": "Analyse completed hires — who was hired, how quickly, and how well they scored.", + "category": "hiring", + "pages": [ + "hired-history" + ], + "status": "active", + "version": 1, + "triggers": [ + "hiring history", + "hire quality", + "quality of hire", + "time to hire", + "who did we hire", + "recent hires" + ], + "owliver": { + "enabled": true, + "suggestions": [ + { + "label": "Who did we hire recently?", + "capability": "list" + }, + { + "label": "How is hiring performance?", + "capability": "summary" + } + ], + "capabilities": [ + "summary", + "stats", + "list", + "table", + "timeline", + "insight" + ], + "responses": { + "summary": { + "title": "Hiring performance", + "source": "hires.performance" + }, + "stats": { + "title": "Hiring performance", + "source": "hires.performance" + }, + "insight": { + "title": "Hiring performance", + "source": "hires.performance" + }, + "list": { + "title": "Recent hires", + "source": "hires.recent", + "limit": 10 + }, + "table": { + "title": "Recent hires", + "source": "hires.recent" + }, + "timeline": { + "title": "Recent hires", + "source": "hires.recent" + } + } + } + }, + "body": "# Hiring History Analysis\n\n## Purpose\n\n- Report hires that have already happened.\n- Report how long they took and how well they scored.\n- Keep the record after the decision separate from the pipeline before it.\n\n## Capabilities\n\n- Summarize total hires, average days to hire, quality of hire and conversion rate.\n- List recent hires with their role and date.\n\n## Data\n\nReads `hires.performance` and `hires.recent`, which join `Staff` to their\n`JobApplication` and `JobPosting` records.\n\n## Analysis\n\nTime to hire is the span between an application arriving and its final update.\nQuality of hire is the average AI score across scored applications. Conversion\nis hires as a share of all applications.\n\n## Output\n\nHeadline hiring figures, and a list or timeline of recent hires.\n\n## Limitations\n\n- This is the record after the decision. Candidates still under consideration\n are Candidate Analysis's question.\n- Time to hire is measured from the application record's timestamps, not from\n when a role was opened.\n- Quality of hire is a screening score, not a performance review. Nothing here\n reports how a hire has since worked out." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "hiring-history-analysis", + "name": "Hiring History Analysis", + "description": "Analyse completed hires — who was hired, how quickly, and how well they scored.", + "status": "active", + "pages": [ + "hired-history" + ], + "kind": "assistant", + "category": "hiring", + "actions": [], + "triggers": [ + "hiring history", + "hire quality", + "quality of hire", + "time to hire", + "who did we hire", + "recent hires" + ], + "declaredTriggers": true, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/skills/owliver/hiring-pulse-analysis.md", + "type": "skill", + "rawBase64": "LS0tCmlkOiBoaXJpbmctcHVsc2UtYW5hbHlzaXMKbmFtZTogSGlyaW5nIFB1bHNlIEFuYWx5c2lzCmRlc2NyaXB0aW9uOiBSZWFkIHRoZSByZWNlbnQgcmh5dGhtIG9mIGhpcmluZyDigJQgYXBwbGljYXRpb25zIGFycml2aW5nLCBhbmQgaG93IHRoZSBmdW5uZWwgaXMgY29udmVydGluZy4KY2F0ZWdvcnk6IGhpcmluZwpwYWdlczoKICAtIGNvbnRyb2wtY2VudGVyCiAgLSBhbmFseXRpY3MKc3RhdHVzOiBhY3RpdmUKdmVyc2lvbjogMQp0cmlnZ2VyczoKICAtIGhpcmluZyBwdWxzZQogIC0gaGlyaW5nIHZlbG9jaXR5CiAgLSBoaXJpbmcgcmh5dGhtCiAgLSBhcHBsaWNhdGlvbiByYXRlCm93bGl2ZXI6CiAgZW5hYmxlZDogdHJ1ZQogIHN1Z2dlc3Rpb25zOgogICAgLSBsYWJlbDogV2hhdCBpcyB0aGUgaGlyaW5nIHB1bHNlPwogICAgICBjYXBhYmlsaXR5OiBzdW1tYXJ5CiAgICAtIGxhYmVsOiBBcHBsaWNhdGlvbnMgb3ZlciByZWNlbnQgcGVyaW9kcwogICAgICBjYXBhYmlsaXR5OiBmbG93CiAgY2FwYWJpbGl0aWVzOgogICAgLSBzdW1tYXJ5CiAgICAtIGZsb3cKICAgIC0gc3RhdHMKICAgIC0gaW5zaWdodAogIHJlc3BvbnNlczoKICAgIHN1bW1hcnk6CiAgICAgIHRpdGxlOiBIaXJpbmcgcHVsc2UKICAgICAgc291cmNlOiBjYW5kaWRhdGVzLmFjdGl2aXR5CiAgICAgIHBlcmlvZHM6CiAgICAgICAgLSB0b2RheQogICAgICAgIC0gbGFzdC03LWRheXMKICAgICAgICAtIHByZXZpb3VzLW1vbnRoCiAgICBmbG93OgogICAgICB0aXRsZTogQXBwbGljYXRpb25zIG92ZXIgdGltZQogICAgICBzb3VyY2U6IGNhbmRpZGF0ZXMuYWN0aXZpdHkKICAgICAgcGVyaW9kczoKICAgICAgICAtIHRvZGF5CiAgICAgICAgLSBsYXN0LTctZGF5cwogICAgICAgIC0gdGhpcy1tb250aAogICAgICAgIC0gcHJldmlvdXMtbW9udGgKICAgIHN0YXRzOgogICAgICB0aXRsZTogSGlyaW5nIHBlcmZvcm1hbmNlCiAgICAgIHNvdXJjZTogaGlyZXMucGVyZm9ybWFuY2UKICAgIGluc2lnaHQ6CiAgICAgIHRpdGxlOiBIaXJpbmcgcGVyZm9ybWFuY2UKICAgICAgc291cmNlOiBoaXJlcy5wZXJmb3JtYW5jZQotLS0KCiMgSGlyaW5nIFB1bHNlIEFuYWx5c2lzCgojIyBQdXJwb3NlCgotIFJlcG9ydCB0aGUgcmVjZW50IHJoeXRobSBvZiBoaXJpbmc6IGhvdyBtYW55IGFwcGxpY2F0aW9ucyBhcmUgYXJyaXZpbmcsIGFuZAogIGhvdyB0aGUgZnVubmVsIGlzIGNvbnZlcnRpbmcgdGhlbS4KLSBEaXN0aW5ndWlzaCBhIHF1aWV0IHdlZWsgZnJvbSBhIGJyb2tlbiBwaXBlbGluZS4KCiMjIENhcGFiaWxpdGllcwoKLSBDb3VudCBhcHBsaWNhdGlvbnMgYXJyaXZpbmcgYWNyb3NzIHJlY2VudCBwZXJpb2RzLgotIFJlcG9ydCBjb252ZXJzaW9uLCBzcGVlZCBhbmQgcXVhbGl0eSBvZiBoaXJlLgoKIyMgRGF0YQoKUmVhZHMgYGNhbmRpZGF0ZXMuYWN0aXZpdHlgIGZvciBhcnJpdmFsIGNvdW50cyBvdmVyIHBlcmlvZHMsIGFuZApgaGlyZXMucGVyZm9ybWFuY2VgIGZvciBjb252ZXJzaW9uLCB0aW1lLXRvLWhpcmUgYW5kIHF1YWxpdHkuCgojIyBBbmFseXNpcwoKQXJyaXZhbCBjb3VudHMgYXJlIHJlcG9ydGVkIHBlciBwZXJpb2QgcmF0aGVyIHRoYW4gYXMgYSBzaW5nbGUgcmF0ZSwgYmVjYXVzZSBhCnJhdGUgYXZlcmFnZXMgYXdheSB0aGUgc2hhcGUg4oCUIHRoaXJ0eSBhcHBsaWNhdGlvbnMgaW4gYSBtb250aCBpcyBhIGRpZmZlcmVudApzaXR1YXRpb24gZGVwZW5kaW5nIG9uIHdoZXRoZXIgdGhleSBhcnJpdmVkIHN0ZWFkaWx5IG9yIGFsbCBvbiBvbmUgZGF5LgoKIyMgT3V0cHV0CgpBcHBsaWNhdGlvbnMgcGVyIHBlcmlvZCwgYW5kIGhlYWRsaW5lIGNvbnZlcnNpb24sIHNwZWVkIGFuZCBxdWFsaXR5IGZpZ3VyZXMuCgojIyBMaW1pdGF0aW9ucwoKLSAqKlRoaXMgaXMgdGhlIGFuYWx5c2lzIGRlZmluaXRpb24gb25seS4qKiBUaGUgSGlyaW5nIFB1bHNlIGNhcmQgdGhhdCBhcHBlYXJzCiAgb24gS3JvdyBwYWdlcyBpcyBhIHNlcGFyYXRlIFVJIGNvbmZpZ3VyYXRpb24gd2l0aCBpdHMgb3duIHBsYWNlbWVudCBhbmQKICBwZXJpb2Qgc2V0dGluZ3M7IHRoZSB0d28gYXJlIGRlbGliZXJhdGVseSBub3QgdGhlIHNhbWUgZGVmaW5pdGlvbiBhbmQgY2hhbmdpbmcKICBvbmUgZG9lcyBub3QgY2hhbmdlIHRoZSBvdGhlci4KLSBBcnJpdmFsIGNvdW50cyBhcmUgYnkgYXBwbGljYXRpb24gY3JlYXRpb24gZGF0ZSwgbm90IGJ5IHdoZW4gYSByb2xlIG9wZW5lZC4KLSBBIHBlcmlvZCB3aXRoIG5vIGFwcGxpY2F0aW9ucyByZXBvcnRzIHplcm8sIHdoaWNoIGlzIGEgcmVhbCByZWFkaW5nIOKAlCBpdCBkb2VzCiAgbm90IGRpc3Rpbmd1aXNoICJub2JvZHkgYXBwbGllZCIgZnJvbSAidGhlIHJvbGUgd2FzIG5vdCBhZHZlcnRpc2VkIi4K", + "bytes": 2382, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "hiring-pulse-analysis", + "name": "Hiring Pulse Analysis", + "description": "Read the recent rhythm of hiring — applications arriving, and how the funnel is converting.", + "category": "hiring", + "pages": [ + "control-center", + "analytics" + ], + "status": "active", + "version": 1, + "triggers": [ + "hiring pulse", + "hiring velocity", + "hiring rhythm", + "application rate" + ], + "owliver": { + "enabled": true, + "suggestions": [ + { + "label": "What is the hiring pulse?", + "capability": "summary" + }, + { + "label": "Applications over recent periods", + "capability": "flow" + } + ], + "capabilities": [ + "summary", + "flow", + "stats", + "insight" + ], + "responses": { + "summary": { + "title": "Hiring pulse", + "source": "candidates.activity", + "periods": [ + "today", + "last-7-days", + "previous-month" + ] + }, + "flow": { + "title": "Applications over time", + "source": "candidates.activity", + "periods": [ + "today", + "last-7-days", + "this-month", + "previous-month" + ] + }, + "stats": { + "title": "Hiring performance", + "source": "hires.performance" + }, + "insight": { + "title": "Hiring performance", + "source": "hires.performance" + } + } + } + }, + "body": "# Hiring Pulse Analysis\n\n## Purpose\n\n- Report the recent rhythm of hiring: how many applications are arriving, and\n how the funnel is converting them.\n- Distinguish a quiet week from a broken pipeline.\n\n## Capabilities\n\n- Count applications arriving across recent periods.\n- Report conversion, speed and quality of hire.\n\n## Data\n\nReads `candidates.activity` for arrival counts over periods, and\n`hires.performance` for conversion, time-to-hire and quality.\n\n## Analysis\n\nArrival counts are reported per period rather than as a single rate, because a\nrate averages away the shape — thirty applications in a month is a different\nsituation depending on whether they arrived steadily or all on one day.\n\n## Output\n\nApplications per period, and headline conversion, speed and quality figures.\n\n## Limitations\n\n- **This is the analysis definition only.** The Hiring Pulse card that appears\n on Krow pages is a separate UI configuration with its own placement and\n period settings; the two are deliberately not the same definition and changing\n one does not change the other.\n- Arrival counts are by application creation date, not by when a role opened.\n- A period with no applications reports zero, which is a real reading — it does\n not distinguish \"nobody applied\" from \"the role was not advertised\"." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "hiring-pulse-analysis", + "name": "Hiring Pulse Analysis", + "description": "Read the recent rhythm of hiring — applications arriving, and how the funnel is converting.", + "status": "active", + "pages": [ + "control-center", + "analytics" + ], + "kind": "assistant", + "category": "hiring", + "actions": [], + "triggers": [ + "hiring pulse", + "hiring velocity", + "hiring rhythm", + "application rate" + ], + "declaredTriggers": true, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/skills/owliver/learning-analysis.md", + "type": "skill", + "rawBase64": "LS0tCmlkOiBsZWFybmluZy1hbmFseXNpcwpuYW1lOiBMZWFybmluZyBBbmFseXNpcwpkZXNjcmlwdGlvbjogUmVwb3J0IHdoYXQgdGhlIHRyYWluaW5nIGxpYnJhcnkgaG9sZHMgYW5kIGhvdyBmYXIgdGhlIHdvcmtmb3JjZSBoYXMgcHJvZ3Jlc3NlZCB0aHJvdWdoIGl0LgpjYXRlZ29yeTogd29ya2ZvcmNlCnBhZ2VzOgogIC0ga3Jvdy1mb3JnZQpzdGF0dXM6IGFjdGl2ZQp2ZXJzaW9uOiAxCnRyaWdnZXJzOgogIC0gbGVhcm5pbmcgYW5hbHlzaXMKICAtIHRyYWluaW5nIHByb2dyZXNzCiAgLSBjb3Vyc2UgcHJvZ3Jlc3MKICAtIHRyYWluaW5nIGxpYnJhcnkKICAtIHdoYXQgdHJhaW5pbmcKb3dsaXZlcjoKICBlbmFibGVkOiB0cnVlCiAgc3VnZ2VzdGlvbnM6CiAgICAtIGxhYmVsOiBIb3cgaXMgdHJhaW5pbmcgcHJvZ3Jlc3Npbmc/CiAgICAgIGNhcGFiaWxpdHk6IHByb2dyZXNzCiAgICAtIGxhYmVsOiBXaGF0IGRvZXMgdGhlIGxpYnJhcnkgaG9sZD8KICAgICAgY2FwYWJpbGl0eTogbGlzdAogIGNhcGFiaWxpdGllczoKICAgIC0gc3VtbWFyeQogICAgLSBwcm9ncmVzcwogICAgLSBsaXN0CiAgICAtIHRhYmxlCiAgICAtIHN0YXRzCiAgcmVzcG9uc2VzOgogICAgc3VtbWFyeToKICAgICAgdGl0bGU6IFRyYWluaW5nIHByb2dyZXNzCiAgICAgIHNvdXJjZTogd29ya2ZvcmNlLnRyYWluaW5nCiAgICBwcm9ncmVzczoKICAgICAgdGl0bGU6IFRyYWluaW5nIHByb2dyZXNzCiAgICAgIHNvdXJjZTogd29ya2ZvcmNlLnRyYWluaW5nCiAgICBsaXN0OgogICAgICB0aXRsZTogVHJhaW5pbmcgcGF0aHMKICAgICAgc291cmNlOiB3b3JrZm9yY2UudHJhaW5pbmcKICAgIHRhYmxlOgogICAgICB0aXRsZTogVHJhaW5pbmcgcGF0aHMKICAgICAgc291cmNlOiB3b3JrZm9yY2UudHJhaW5pbmcKICAgIHN0YXRzOgogICAgICB0aXRsZTogVHJhaW5pbmcgcHJvZ3Jlc3MKICAgICAgc291cmNlOiB3b3JrZm9yY2UudHJhaW5pbmcKLS0tCgojIExlYXJuaW5nIEFuYWx5c2lzCgojIyBQdXJwb3NlCgotIFJlcG9ydCB3aGF0IHRoZSB0cmFpbmluZyBsaWJyYXJ5IGhvbGRzLgotIFJlcG9ydCBob3cgZmFyIHRoZSB3b3JrZm9yY2UgaGFzIHByb2dyZXNzZWQgYWdhaW5zdCBpdC4KCiMjIENhcGFiaWxpdGllcwoKLSBTdW1tYXJpemUgdHJhaW5pbmcgcGF0aHMgYW5kIHByb2dyZXNzIGFnYWluc3QgdGhlbS4KLSBMaXN0IG9yIHRhYnVsYXRlIHRoZSBwYXRocyBpbiB0aGUgbGlicmFyeS4KCiMjIERhdGEKClJlYWRzIGB3b3JrZm9yY2UudHJhaW5pbmdgLCB0aGUgZXhpc3Rpbmcgc291cmNlIGJlaGluZCB0aGUgRm9yZ2UgcHJvZ3Jlc3Npb24Kdmlld3MuCgojIyBBbmFseXNpcwoKUHJvZ3Jlc3MgaXMgY291bnRlZCBhZ2FpbnN0IHRoZSBwYXRocyB0aGUgbGlicmFyeSBhY3R1YWxseSBkZWZpbmVzLCBzbyBhZGRpbmcgYQpwYXRoIGNoYW5nZXMgdGhlIGRlbm9taW5hdG9yIHJhdGhlciB0aGFuIGJlaW5nIHJlcG9ydGVkIGFzIGEgc3VkZGVuIGZhbGwgaW4KY29tcGxldGlvbi4KCiMjIE91dHB1dAoKVHJhaW5pbmcgcGF0aHMgd2l0aCBwcm9ncmVzcyBhZ2FpbnN0IGVhY2guCgojIyBMaW1pdGF0aW9ucwoKLSBBIHNraWxsIGluIEZvcmdlIGlzIHNvbWV0aGluZyBhIHBlcnNvbiBsZWFybnMgYW5kIGlzIHZlcmlmaWVkIGluLiBJdCBpcyBub3QgYW4KICBPd2xpdmVyIGNhcGFiaWxpdHksIGFuZCB0aGUgdHdvIG11c3Qgbm90IGJlIGRlc2NyaWJlZCBhcyB0aGUgc2FtZSB0aGluZy4KLSBQcm9ncmVzcyBpcyBwZXJzb25hbCB0byB0aGUgc2lnbmVkLWluIHdvcmtlciB3aGVyZSB0aGVpciByZWNvcmQgaXMgbG9hZGVkLCBhbmQKICBsaWJyYXJ5LWxldmVsIG90aGVyd2lzZS4KLSBUaGlzIHJlcG9ydHMgcHJvZ3Jlc3Npb24sIG5vdCB3aGV0aGVyIHRoZSB0cmFpbmluZyBpcyBhbnkgZ29vZC4K", + "bytes": 1908, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "learning-analysis", + "name": "Learning Analysis", + "description": "Report what the training library holds and how far the workforce has progressed through it.", + "category": "workforce", + "pages": [ + "krow-forge" + ], + "status": "active", + "version": 1, + "triggers": [ + "learning analysis", + "training progress", + "course progress", + "training library", + "what training" + ], + "owliver": { + "enabled": true, + "suggestions": [ + { + "label": "How is training progressing?", + "capability": "progress" + }, + { + "label": "What does the library hold?", + "capability": "list" + } + ], + "capabilities": [ + "summary", + "progress", + "list", + "table", + "stats" + ], + "responses": { + "summary": { + "title": "Training progress", + "source": "workforce.training" + }, + "progress": { + "title": "Training progress", + "source": "workforce.training" + }, + "list": { + "title": "Training paths", + "source": "workforce.training" + }, + "table": { + "title": "Training paths", + "source": "workforce.training" + }, + "stats": { + "title": "Training progress", + "source": "workforce.training" + } + } + } + }, + "body": "# Learning Analysis\n\n## Purpose\n\n- Report what the training library holds.\n- Report how far the workforce has progressed against it.\n\n## Capabilities\n\n- Summarize training paths and progress against them.\n- List or tabulate the paths in the library.\n\n## Data\n\nReads `workforce.training`, the existing source behind the Forge progression\nviews.\n\n## Analysis\n\nProgress is counted against the paths the library actually defines, so adding a\npath changes the denominator rather than being reported as a sudden fall in\ncompletion.\n\n## Output\n\nTraining paths with progress against each.\n\n## Limitations\n\n- A skill in Forge is something a person learns and is verified in. It is not an\n Owliver capability, and the two must not be described as the same thing.\n- Progress is personal to the signed-in worker where their record is loaded, and\n library-level otherwise.\n- This reports progression, not whether the training is any good." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "learning-analysis", + "name": "Learning Analysis", + "description": "Report what the training library holds and how far the workforce has progressed through it.", + "status": "active", + "pages": [ + "krow-forge" + ], + "kind": "assistant", + "category": "workforce", + "actions": [], + "triggers": [ + "learning analysis", + "training progress", + "course progress", + "training library", + "what training" + ], + "declaredTriggers": true, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/skills/owliver/operational-risk.md", + "type": "skill", + "rawBase64": "LS0tCmlkOiBvcGVyYXRpb25hbC1yaXNrCm5hbWU6IE9wZXJhdGlvbmFsIFJpc2sKZGVzY3JpcHRpb246IFN1cmZhY2Ugd2hhdCBpcyBnb2luZyB3cm9uZyBvcGVyYXRpb25hbGx5IGFjcm9zcyBoaXJpbmcgYW5kIHRoZSByb3N0ZXIuCmNhdGVnb3J5OiBvcGVyYXRpb25zCnBhZ2VzOgogIC0gY29udHJvbC1jZW50ZXIKICAtIGFjdGl2aXR5CnN0YXR1czogYWN0aXZlCnZlcnNpb246IDEKdHJpZ2dlcnM6CiAgLSBvcGVyYXRpb25hbCByaXNrCiAgLSBvcGVyYXRpb25zIHJpc2sKICAtIHdoYXQgaXMgZ29pbmcgd3JvbmcKICAtIGJhY2tsb2cKICAtIGRlY2lzaW9ucyBvd2VkCm93bGl2ZXI6CiAgZW5hYmxlZDogdHJ1ZQogIHN1Z2dlc3Rpb25zOgogICAgLSBsYWJlbDogV2hhdCBpcyBnb2luZyB3cm9uZyBvcGVyYXRpb25hbGx5PwogICAgICBjYXBhYmlsaXR5OiBsaXN0CiAgICAtIGxhYmVsOiBTdW1tYXJpemUgb3BlcmF0aW9uYWwgcmlzawogICAgICBjYXBhYmlsaXR5OiBzdW1tYXJ5CiAgY2FwYWJpbGl0aWVzOgogICAgLSBzdW1tYXJ5CiAgICAtIGxpc3QKICAgIC0gdGFibGUKICAgIC0gc3RhdHMKICAgIC0gaW5zaWdodAogIHJlc3BvbnNlczoKICAgIHN1bW1hcnk6CiAgICAgIHRpdGxlOiBPcGVyYXRpb25hbCByaXNrCiAgICAgIHNvdXJjZTogb3BlcmF0aW9ucy5yaXNrCiAgICBsaXN0OgogICAgICB0aXRsZTogT3BlcmF0aW9uYWwgcmlza3MKICAgICAgc291cmNlOiBvcGVyYXRpb25zLnJpc2sKICAgIHRhYmxlOgogICAgICB0aXRsZTogT3BlcmF0aW9uYWwgcmlza3MKICAgICAgc291cmNlOiBvcGVyYXRpb25zLnJpc2sKICAgIHN0YXRzOgogICAgICB0aXRsZTogT3BlcmF0aW9uYWwgcmlzawogICAgICBzb3VyY2U6IG9wZXJhdGlvbnMucmlzawogICAgaW5zaWdodDoKICAgICAgdGl0bGU6IEJpZ2dlc3Qgb3BlcmF0aW9uYWwgcmlzawogICAgICBzb3VyY2U6IG9wZXJhdGlvbnMucmlzawotLS0KCiMgT3BlcmF0aW9uYWwgUmlzawoKIyMgUHVycG9zZQoKLSBTdXJmYWNlIHRoZSBvcGVyYXRpb25hbCBwcm9ibGVtcyB0aGF0IG5lZWQgc29tZWJvZHkgdG8gYWN0LgotIERyYXcgdGhlbSBmcm9tIGV2ZXJ5IGRvbWFpbiwgYmVjYXVzZSB0aGV5IGZlZWwgbGlrZSBvbmUgcHJvYmxlbSB0byB0aGUgcGVyc29uCiAgd2hvIGhhcyB0byBmaXggdGhlbS4KLSBSZXBvcnQgbm90aGluZyB3aGVuIHRoZSBvcGVyYXRpb24gaXMgcnVubmluZy4KCiMjIENhcGFiaWxpdGllcwoKLSBEZXRlY3Qgc3Ryb25nIGNhbmRpZGF0ZXMgbGVmdCBhd2FpdGluZyBhIGRlY2lzaW9uLgotIERldGVjdCBhbiB1bnNjcmVlbmVkIGFwcGxpY2F0aW9uIGJhY2tsb2cuCi0gRGV0ZWN0IG9wZW4gcm9sZXMgd2l0aCBubyBhcHBsaWNhbnRzLgotIERldGVjdCBzaGlmdHMgZ29pbmcgdW53b3JrZWQuCgojIyBEYXRhCgpSZWFkcyBgb3BlcmF0aW9ucy5yaXNrYCwgd2hpY2ggam9pbnMgYEpvYkFwcGxpY2F0aW9uYCwgYEpvYlBvc3RpbmdgIGFuZApgU2hpZnRSZWNvcmRgLgoKIyMgQW5hbHlzaXMKCkZvdXIgY2hlY2tzLCBlYWNoIHdpdGggYSBmbG9vciBzbyBvcmRpbmFyeSBvcGVyYXRpb24gZG9lcyBub3QgdHJpcCB0aGVtOgoKMS4gKipEZWNpc2lvbnMgb3dlZCoqIOKAlCBhIGNhbmRpZGF0ZSBzY29yaW5nIDcwIG9yIGFib3ZlLCBzY3JlZW5lZCBvcgogICBzaG9ydGxpc3RlZCwgYW5kIG5vdCBtb3ZlZCBvbi4gQW55IHN1Y2ggY2FuZGlkYXRlIGNvdW50cy4KMi4gKipVbnNjcmVlbmVkIGJhY2tsb2cqKiDigJQgdGhyZWUgb3IgbW9yZSBhcHBsaWNhdGlvbnMgd2l0aCBubyBzY29yZS4KMy4gKipSb2xlcyB3aXRoIG5vIGFwcGxpY2FudHMqKiDigJQgYW55IG9wZW4gcm9sZSBub2JvZHkgaGFzIGFwcGxpZWQgdG8uCjQuICoqU2hpZnRzIHVud29ya2VkKiog4oCUIHR3byBvciBtb3JlIGFic2VuY2VzIG9yIG5vLXNob3dzIGluIHRoZSBsYXN0IDcgZGF5cy4KCkZpbmRpbmdzIGFyZSBvcmRlcmVkIG1vc3Qgc2V2ZXJlIGZpcnN0LiBBIGZpbmRpbmcgaXMgaW5jbHVkZWQgb25seSB3aGVuIGl0CmV4aXN0czsgYW4gZW1wdHkgbGlzdCBtZWFucyB0aGUgb3BlcmF0aW9uIGlzIHJ1bm5pbmcsIG5vdCB0aGF0IHRoZSBjaGVjayB3YXMKc2tpcHBlZC4KCiMjIE91dHB1dAoKQSBjb3VudCBvZiBvcGVuIHJpc2tzLCB0aGVuIGEgcm93IHBlciByaXNrIG5hbWluZyB3aGF0IGlzIHdyb25nIGFuZCB0aGUgZmlndXJlcwpiZWhpbmQgaXQuCgojIyBMaW1pdGF0aW9ucwoKLSBUaHJlc2hvbGRzIGFyZSBmaXhlZCByYXRoZXIgdGhhbiB0dW5lZCB0byB0aGlzIHdvcmtzcGFjZSdzIHZvbHVtZS4KLSAiRGVjaXNpb25zIG93ZWQiIGFzc3VtZXMgYSBzY3JlZW5lZCwgc3Ryb25nIGNhbmRpZGF0ZSBzaG91bGQgYmUgcHJvZ3Jlc3NlZC4KICBBIGNhbmRpZGF0ZSBkZWxpYmVyYXRlbHkgaGVsZCBpcyBpbmRpc3Rpbmd1aXNoYWJsZSBmcm9tIG9uZSBvdmVybG9va2VkLgotIFRoZSBzaGlmdCBjaGVjayBjb3ZlcnMgdGhlIGxhc3QgNyBkYXlzIG9ubHk7IGEgbG9uZ2VyIHBhdHRlcm4gaXMgQXR0ZW5kYW5jZQogIEFuYWx5c2lzJ3MgcXVlc3Rpb24uCg==", + "bytes": 2545, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "operational-risk", + "name": "Operational Risk", + "description": "Surface what is going wrong operationally across hiring and the roster.", + "category": "operations", + "pages": [ + "control-center", + "activity" + ], + "status": "active", + "version": 1, + "triggers": [ + "operational risk", + "operations risk", + "what is going wrong", + "backlog", + "decisions owed" + ], + "owliver": { + "enabled": true, + "suggestions": [ + { + "label": "What is going wrong operationally?", + "capability": "list" + }, + { + "label": "Summarize operational risk", + "capability": "summary" + } + ], + "capabilities": [ + "summary", + "list", + "table", + "stats", + "insight" + ], + "responses": { + "summary": { + "title": "Operational risk", + "source": "operations.risk" + }, + "list": { + "title": "Operational risks", + "source": "operations.risk" + }, + "table": { + "title": "Operational risks", + "source": "operations.risk" + }, + "stats": { + "title": "Operational risk", + "source": "operations.risk" + }, + "insight": { + "title": "Biggest operational risk", + "source": "operations.risk" + } + } + } + }, + "body": "# Operational Risk\n\n## Purpose\n\n- Surface the operational problems that need somebody to act.\n- Draw them from every domain, because they feel like one problem to the person\n who has to fix them.\n- Report nothing when the operation is running.\n\n## Capabilities\n\n- Detect strong candidates left awaiting a decision.\n- Detect an unscreened application backlog.\n- Detect open roles with no applicants.\n- Detect shifts going unworked.\n\n## Data\n\nReads `operations.risk`, which joins `JobApplication`, `JobPosting` and\n`ShiftRecord`.\n\n## Analysis\n\nFour checks, each with a floor so ordinary operation does not trip them:\n\n1. **Decisions owed** — a candidate scoring 70 or above, screened or\n shortlisted, and not moved on. Any such candidate counts.\n2. **Unscreened backlog** — three or more applications with no score.\n3. **Roles with no applicants** — any open role nobody has applied to.\n4. **Shifts unworked** — two or more absences or no-shows in the last 7 days.\n\nFindings are ordered most severe first. A finding is included only when it\nexists; an empty list means the operation is running, not that the check was\nskipped.\n\n## Output\n\nA count of open risks, then a row per risk naming what is wrong and the figures\nbehind it.\n\n## Limitations\n\n- Thresholds are fixed rather than tuned to this workspace's volume.\n- \"Decisions owed\" assumes a screened, strong candidate should be progressed.\n A candidate deliberately held is indistinguishable from one overlooked.\n- The shift check covers the last 7 days only; a longer pattern is Attendance\n Analysis's question." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "operational-risk", + "name": "Operational Risk", + "description": "Surface what is going wrong operationally across hiring and the roster.", + "status": "active", + "pages": [ + "control-center", + "activity" + ], + "kind": "assistant", + "category": "operations", + "actions": [], + "triggers": [ + "operational risk", + "operations risk", + "what is going wrong", + "backlog", + "decisions owed" + ], + "declaredTriggers": true, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/skills/owliver/overtime-analysis.md", + "type": "skill", + "rawBase64": "LS0tCmlkOiBvdmVydGltZS1hbmFseXNpcwpuYW1lOiBPdmVydGltZSBBbmFseXNpcwpkZXNjcmlwdGlvbjogQW5hbHlzZSBvdmVydGltZSBob3Vycywgd2hvIGlzIGNhcnJ5aW5nIHRoZW0sIGFuZCB3aGV0aGVyIHRoZXkgYXJlIGdyb3dpbmcuCmNhdGVnb3J5OiB3b3JrZm9yY2UKcGFnZXM6CiAgLSBhbmFseXRpY3MKICAtIGNvbnRyb2wtY2VudGVyCnN0YXR1czogYWN0aXZlCnZlcnNpb246IDEKdHJpZ2dlcnM6CiAgLSBvdmVydGltZQogIC0gZXh0cmEgaG91cnMKICAtIGhvdXJzIHdvcmtlZAogIC0gd29ya2luZyBsYXRlCm93bGl2ZXI6CiAgZW5hYmxlZDogdHJ1ZQogIHN1Z2dlc3Rpb25zOgogICAgLSBsYWJlbDogSG93IG11Y2ggb3ZlcnRpbWUgYXJlIHdlIHJ1bm5pbmc/CiAgICAgIGNhcGFiaWxpdHk6IHN1bW1hcnkKICAgIC0gbGFiZWw6IFdobyBpcyB3b3JraW5nIHRoZSBtb3N0IG92ZXJ0aW1lPwogICAgICBjYXBhYmlsaXR5OiB0YWJsZQogICAgLSBsYWJlbDogT3ZlcnRpbWUgb3ZlciByZWNlbnQgcGVyaW9kcwogICAgICBjYXBhYmlsaXR5OiBmbG93CiAgY2FwYWJpbGl0aWVzOgogICAgLSBzdW1tYXJ5CiAgICAtIHN0YXRzCiAgICAtIHRhYmxlCiAgICAtIHByb2dyZXNzCiAgICAtIGZsb3cKICAgIC0gaW5zaWdodAogIHJlc3BvbnNlczoKICAgIHN1bW1hcnk6CiAgICAgIHRpdGxlOiBPdmVydGltZQogICAgICBzb3VyY2U6IHdvcmtmb3JjZS5vdmVydGltZQogICAgc3RhdHM6CiAgICAgIHRpdGxlOiBPdmVydGltZQogICAgICBzb3VyY2U6IHdvcmtmb3JjZS5vdmVydGltZQogICAgdGFibGU6CiAgICAgIHRpdGxlOiBPdmVydGltZSBieSBwZXJzb24KICAgICAgc291cmNlOiB3b3JrZm9yY2Uub3ZlcnRpbWUKICAgIHByb2dyZXNzOgogICAgICB0aXRsZTogT3ZlcnRpbWUgYnkgcGVyc29uCiAgICAgIHNvdXJjZTogd29ya2ZvcmNlLm92ZXJ0aW1lCiAgICBmbG93OgogICAgICB0aXRsZTogT3ZlcnRpbWUgb3ZlciB0aW1lCiAgICAgIHNvdXJjZTogd29ya2ZvcmNlLm92ZXJ0aW1lCiAgICAgIHBlcmlvZHM6CiAgICAgICAgLSBsYXN0LTctZGF5cwogICAgICAgIC0gdGhpcy1tb250aAogICAgICAgIC0gcHJldmlvdXMtbW9udGgKICAgIGluc2lnaHQ6CiAgICAgIHRpdGxlOiBPdmVydGltZQogICAgICBzb3VyY2U6IHdvcmtmb3JjZS5vdmVydGltZQotLS0KCiMgT3ZlcnRpbWUgQW5hbHlzaXMKCiMjIFB1cnBvc2UKCi0gUmVwb3J0IGhvdyBtdWNoIG92ZXJ0aW1lIHRoZSB3b3JrZm9yY2UgaXMgY2FycnlpbmcuCi0gU2F5IHdobyBpcyBjYXJyeWluZyBpdCwgc2luY2UgYSB0b3RhbCBzcHJlYWQgZXZlbmx5IGFuZCBhIHRvdGFsIHNpdHRpbmcgb24gb25lCiAgcGVyc29uIGFyZSBkaWZmZXJlbnQgcHJvYmxlbXMuCi0gU2hvdyB3aGV0aGVyIGl0IGlzIGdyb3dpbmcuCgojIyBDYXBhYmlsaXRpZXMKCi0gU3VtbWFyaXplIG92ZXJ0aW1lIGhvdXJzIGFuZCB0aGVpciBzaGFyZSBvZiBzY2hlZHVsZWQgdGltZS4KLSBDb21wYXJlIG92ZXJ0aW1lIHBlciBwZXJzb24sIG1vc3QgaG91cnMgZmlyc3QuCi0gU2hvdyBvdmVydGltZSBhY3Jvc3MgcmVjZW50IHBlcmlvZHMuCgojIyBEYXRhCgpSZWFkcyBgd29ya2ZvcmNlLm92ZXJ0aW1lYCwgd2hpY2ggY29tcGFyZXMgc2NoZWR1bGVkIGhvdXJzIHRvIGhvdXJzIGFjdHVhbGx5CndvcmtlZCBhY3Jvc3MgYFNoaWZ0UmVjb3JkYCBlbnRyaWVzLgoKIyMgQW5hbHlzaXMKCk92ZXJ0aW1lIGlzIHJlcG9ydGVkIGJvdGggYXMgaG91cnMgYW5kIGFzIGEgc2hhcmUgb2Ygc2NoZWR1bGVkIHRpbWUuIFRoZSByYXRpbwppcyB3aGF0IG1ha2VzIHR3byB0ZWFtcyBjb21wYXJhYmxlIOKAlCBmb3J0eSBob3VycyBtZWFucyBvbmUgdGhpbmcgYWNyb3NzIGEKZm9ydG5pZ2h0IGFuZCBhbm90aGVyIGFjcm9zcyBhIHllYXIuCgpBdHRlbmRhbmNlIGFuZCBvdmVydGltZSBhcmUga2VwdCBzZXBhcmF0ZSBiZWNhdXNlIG9uZSBoaWRlcyB0aGUgb3RoZXI6IGEgdGVhbQpjYW4gaGF2ZSBwZXJmZWN0IGF0dGVuZGFuY2UgYW5kIGJlIHJ1bm5pbmcgb24gdGhpcnR5IGhvdXJzIG9mIG92ZXJ0aW1lIGEgd2VlaywKYW5kIGEgc2luZ2xlICJ3b3JrZm9yY2UgaG91cnMiIGZpZ3VyZSB3b3VsZCByZXBvcnQgdGhhdCBhcyBoZWFsdGh5LgoKIyMgT3V0cHV0CgpUb3RhbCBvdmVydGltZSBob3Vycywgc2hhcmUgb2Ygc2NoZWR1bGVkIHRpbWUsIGF2ZXJhZ2UgcGVyIHNoaWZ0LCBhbmQgYSByb3cgcGVyCnBlcnNvbi4gT3ZlciBwZXJpb2RzLCBob3VycyBwZXIgd2luZG93LgoKIyMgTGltaXRhdGlvbnMKCi0gT3ZlcnRpbWUgaXMgZGVyaXZlZCBmcm9tIHJlY29yZGVkIHNoaWZ0IGVuZCB0aW1lcywgbm90IGZyb20gYW4gYXBwcm92YWxzCiAgcHJvY2Vzcy4gQSBzaGlmdCB0aGF0IHJhbiBsb25nIGFwcGVhcnMgaGVyZSB3aGV0aGVyIG9yIG5vdCBpdCB3YXMgYXV0aG9yaXNlZC4KLSBBIHBlcnNvbiB3aXRoIG5vIG92ZXJ0aW1lIGFwcGVhcnMgd2l0aCB6ZXJvLCB3aGljaCBpcyBhIHJlYWwgcmVhZGluZyByYXRoZXIKICB0aGFuIG1pc3NpbmcgZGF0YS4KLSBDb3N0IGlzIG5vdCBjYWxjdWxhdGVkLiBObyBwYXkgcmF0ZSBpcyBhdHRhY2hlZCB0byBhIHNoaWZ0IHJlY29yZC4K", + "bytes": 2601, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "overtime-analysis", + "name": "Overtime Analysis", + "description": "Analyse overtime hours, who is carrying them, and whether they are growing.", + "category": "workforce", + "pages": [ + "analytics", + "control-center" + ], + "status": "active", + "version": 1, + "triggers": [ + "overtime", + "extra hours", + "hours worked", + "working late" + ], + "owliver": { + "enabled": true, + "suggestions": [ + { + "label": "How much overtime are we running?", + "capability": "summary" + }, + { + "label": "Who is working the most overtime?", + "capability": "table" + }, + { + "label": "Overtime over recent periods", + "capability": "flow" + } + ], + "capabilities": [ + "summary", + "stats", + "table", + "progress", + "flow", + "insight" + ], + "responses": { + "summary": { + "title": "Overtime", + "source": "workforce.overtime" + }, + "stats": { + "title": "Overtime", + "source": "workforce.overtime" + }, + "table": { + "title": "Overtime by person", + "source": "workforce.overtime" + }, + "progress": { + "title": "Overtime by person", + "source": "workforce.overtime" + }, + "flow": { + "title": "Overtime over time", + "source": "workforce.overtime", + "periods": [ + "last-7-days", + "this-month", + "previous-month" + ] + }, + "insight": { + "title": "Overtime", + "source": "workforce.overtime" + } + } + } + }, + "body": "# Overtime Analysis\n\n## Purpose\n\n- Report how much overtime the workforce is carrying.\n- Say who is carrying it, since a total spread evenly and a total sitting on one\n person are different problems.\n- Show whether it is growing.\n\n## Capabilities\n\n- Summarize overtime hours and their share of scheduled time.\n- Compare overtime per person, most hours first.\n- Show overtime across recent periods.\n\n## Data\n\nReads `workforce.overtime`, which compares scheduled hours to hours actually\nworked across `ShiftRecord` entries.\n\n## Analysis\n\nOvertime is reported both as hours and as a share of scheduled time. The ratio\nis what makes two teams comparable — forty hours means one thing across a\nfortnight and another across a year.\n\nAttendance and overtime are kept separate because one hides the other: a team\ncan have perfect attendance and be running on thirty hours of overtime a week,\nand a single \"workforce hours\" figure would report that as healthy.\n\n## Output\n\nTotal overtime hours, share of scheduled time, average per shift, and a row per\nperson. Over periods, hours per window.\n\n## Limitations\n\n- Overtime is derived from recorded shift end times, not from an approvals\n process. A shift that ran long appears here whether or not it was authorised.\n- A person with no overtime appears with zero, which is a real reading rather\n than missing data.\n- Cost is not calculated. No pay rate is attached to a shift record." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "overtime-analysis", + "name": "Overtime Analysis", + "description": "Analyse overtime hours, who is carrying them, and whether they are growing.", + "status": "active", + "pages": [ + "analytics", + "control-center" + ], + "kind": "assistant", + "category": "workforce", + "actions": [], + "triggers": [ + "overtime", + "extra hours", + "hours worked", + "working late" + ], + "declaredTriggers": true, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/skills/owliver/staffing-risk.md", + "type": "skill", + "rawBase64": "LS0tCmlkOiBzdGFmZmluZy1yaXNrCm5hbWU6IFN0YWZmaW5nIFJpc2sKZGVzY3JpcHRpb246IElkZW50aWZ5IG9wZW4gcm9sZXMgdGhhdCB3aWxsIG5vdCBmaWxsIG9uIHRoZWlyIG93biwgYW5kIHNheSB3aHkuCmNhdGVnb3J5OiB3b3JrZm9yY2UKcGFnZXM6CiAgLSBwb3NpdGlvbnMKICAtIGNvbnRyb2wtY2VudGVyCnN0YXR1czogYWN0aXZlCnZlcnNpb246IDEKdHJpZ2dlcnM6CiAgLSBzdGFmZmluZyByaXNrCiAgLSBzdGFmZmluZyBnYXAKICAjIGAqYCBzdGFuZHMgZm9yIGFueXRoaW5nIGluIGJldHdlZW4sIHNvIG9uZSBsaW5lIGNvdmVycyAicm9sZXMgYXQgcmlzayIsCiAgIyAicm9sZXMgdGhhdCBhcmUgYXQgcmlzayIgYW5kICJyb2xlcyBtb3N0IGF0IHJpc2siIHdpdGhvdXQgbGlzdGluZyBlYWNoLgogIC0gcm9sZXMqYXQgcmlzawogIC0gcG9zaXRpb25zKmF0IHJpc2sKICAtIHVuZGVyc3RhZmZlZApvd2xpdmVyOgogIGVuYWJsZWQ6IHRydWUKICBzdWdnZXN0aW9uczoKICAgIC0gbGFiZWw6IFdoaWNoIHJvbGVzIGFyZSBhdCByaXNrPwogICAgICBjYXBhYmlsaXR5OiBsaXN0CiAgICAtIGxhYmVsOiBTdW1tYXJpemUgc3RhZmZpbmcgcmlzawogICAgICBjYXBhYmlsaXR5OiBzdW1tYXJ5CiAgY2FwYWJpbGl0aWVzOgogICAgLSBzdW1tYXJ5CiAgICAtIGxpc3QKICAgIC0gdGFibGUKICAgIC0gaW5zaWdodAogIHJlc3BvbnNlczoKICAgIHN1bW1hcnk6CiAgICAgIHRpdGxlOiBTdGFmZmluZyByaXNrCiAgICAgIHNvdXJjZTogcG9zaXRpb25zLnJpc2sKICAgIGxpc3Q6CiAgICAgIHRpdGxlOiBSb2xlcyBhdCByaXNrCiAgICAgIHNvdXJjZTogcG9zaXRpb25zLnJpc2sKICAgICAgbGltaXQ6IDUKICAgIHRhYmxlOgogICAgICB0aXRsZTogUm9sZXMgYXQgcmlzawogICAgICBzb3VyY2U6IHBvc2l0aW9ucy5yaXNrCiAgICBpbnNpZ2h0OgogICAgICB0aXRsZTogQmlnZ2VzdCBzdGFmZmluZyByaXNrCiAgICAgIHNvdXJjZTogcG9zaXRpb25zLnJpc2sKLS0tCgojIFN0YWZmaW5nIFJpc2sKCiMjIFB1cnBvc2UKCi0gTmFtZSB0aGUgb3BlbiByb2xlcyB0aGF0IGFyZSBub3QgZ29pbmcgdG8gZmlsbCB3aXRob3V0IGludGVydmVudGlvbi4KLSBTYXkgd2hpY2ggb2YgZm91ciBkaXN0aW5jdCBwcm9ibGVtcyBlYWNoIG9uZSBoYXMsIGJlY2F1c2UgZWFjaCBoYXMgYQogIGRpZmZlcmVudCBmaXguCi0gUmFuayB0aGVtIHNvIHRoZSByZWFkZXIga25vd3Mgd2hpY2ggdG8gZGVhbCB3aXRoIGZpcnN0LgoKIyMgQ2FwYWJpbGl0aWVzCgotIENvdW50IHRoZSBvcGVuIHJvbGVzIGN1cnJlbnRseSBhdCByaXNrLgotIExpc3QgdGhvc2Ugcm9sZXMsIHdvcnN0IGZpcnN0LCB3aXRoIHRoZSByZWFzb24gZm9yIGVhY2guCi0gSWRlbnRpZnkgdGhlIHNpbmdsZSByb2xlIG1vc3QgaW4gbmVlZCBvZiBhdHRlbnRpb24uCgojIyBEYXRhCgpSZWFkcyBgcG9zaXRpb25zLnJpc2tgLCB3aGljaCBqb2lucyBvcGVuIGBKb2JQb3N0aW5nYCByZWNvcmRzIHRvIHRoZWlyCmBKb2JBcHBsaWNhdGlvbmAgcmVjb3JkcyB0aHJvdWdoIHRoZSBzYW1lIGBidWlsZFBvc2l0aW9uYCByZWFkaW5nIHRoZSBQb3NpdGlvbnMKcGFnZSByZW5kZXJzIGZyb20uIE5vIHNlcGFyYXRlIGNhbGN1bGF0aW9uLCBzbyBhIHJpc2sgcmVwb3J0ZWQgaGVyZSBhbmQgYSBoZWFsdGgKYmFkZ2Ugc2hvd24gdGhlcmUgY2Fubm90IGRpc2FncmVlLgoKIyMgQW5hbHlzaXMKCkEgcm9sZSBpcyBhdCByaXNrIHdoZW4gYW55IG9mIHRoZSBmb2xsb3dpbmcgaXMgdHJ1ZS4gVGhleSBhcmUga2VwdCBhcGFydCByYXRoZXIKdGhhbiBjb21iaW5lZCBpbnRvIGEgc2NvcmUsIGJlY2F1c2UgdGhlIHNjb3JlIHdvdWxkIGhpZGUgdGhlIG9ubHkgcGFydCB0aGF0CnRlbGxzIHRoZSByZWFkZXIgd2hhdCB0byBkbzoKCjEuICoqTm8gYXBwbGljYW50cyB5ZXQqKiDigJQgbm9ib2R5IGhhcyBhcHBsaWVkLiBOZWVkcyBzb3VyY2luZy4KMi4gKipObyBjYW5kaWRhdGUgc2NvcmluZyA3MCBvciBhYm92ZSoqIOKAlCBwZW9wbGUgYXBwbGllZCwgbm9uZSBhcmUgdmlhYmxlLgogICBOZWVkcyB0aGUgcmVxdWlyZW1lbnRzIG9yIHRoZSBwYXkgcmV2aXNpdGluZy4KMy4gKipUaHJlZSBvciBtb3JlIHVuc2NyZWVuZWQqKiDigJQgYSBiYWNrbG9nIG5vYm9keSBoYXMgbG9va2VkIGF0LiBOZWVkcwogICBzY3JlZW5pbmcuCjQuICoqU29tZW9uZSBhd2FpdGluZyBhIGRlY2lzaW9uKiog4oCUIGEgc3Ryb25nIGNhbmRpZGF0ZSBoYXMgYmVlbiBzY3JlZW5lZCBhbmQKICAgbm90IG1vdmVkIG9uLiBOZWVkcyBhIHBlcnNvbiB0byBkZWNpZGUuCgpSYW5raW5nIHdlaWdodHMgYW4gZW1wdHkgcGlwZWxpbmUgYWJvdmUgYSBidXN5IG9uZSB0aGF0IG5lZWRzIGF0dGVudGlvbiwgc2luY2UKYW4gZW1wdHkgcGlwZWxpbmUgdGFrZXMgbG9uZ2VzdCB0byByZWNvdmVyLgoKIyMgT3V0cHV0CgpBIGNvdW50IG9mIHJvbGVzIGF0IHJpc2ssIHRoZW4gYSByb3cgcGVyIHJvbGUgbmFtaW5nIGl0cyBkZXBhcnRtZW50IGFuZCBpdHMKcmVhc29ucy4gQSByb2xlIHdpdGggbm8gcHJvYmxlbXMgaXMgbm90IGxpc3RlZC4KCiMjIExpbWl0YXRpb25zCgotIE9ubHkgb3BlbiByb2xlcyBhcmUgY29uc2lkZXJlZC4gQSBwYXVzZWQgb3IgY2xvc2VkIHJvbGUgaXMgbm90IGF0IHJpc2suCi0gIlZpYWJsZSIgbWVhbnMgYW4gQUkgc2NvcmUgb2YgNzAgb3IgYWJvdmUuIEFuIHVuc2NvcmVkIGNhbmRpZGF0ZSBpcyBub3QKICBjb3VudGVkIGFzIHZpYWJsZSwgc28gYSByb2xlIHdob3NlIGFwcGxpY2FudHMgbm9ib2R5IGhhcyBzY3JlZW5lZCB3aWxsIHJlcG9ydAogIGJvdGggYW4gdW5zY3JlZW5lZCBiYWNrbG9nIGFuZCBubyB2aWFibGUgY2FuZGlkYXRlIOKAlCB0aG9zZSBhcmUgdHdvIHRydWUKICBzdGF0ZW1lbnRzIGFib3V0IHRoZSBzYW1lIGNhdXNlLgotIFRoaXMgcmVhZHMgdGhlIHBpcGVsaW5lLCBub3QgdGhlIHJvc3Rlci4gSXQgZG9lcyBub3Qga25vdyBob3cgbWFueSBwZW9wbGUgYQogIHJvbGUgbmVlZHMsIGJlY2F1c2Ugbm8gcG9zaXRpb24gaW4gdGhpcyB3b3Jrc3BhY2Ugc3RhdGVzIGEgaGVhZGNvdW50Lgo=", + "bytes": 3068, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "staffing-risk", + "name": "Staffing Risk", + "description": "Identify open roles that will not fill on their own, and say why.", + "category": "workforce", + "pages": [ + "positions", + "control-center" + ], + "status": "active", + "version": 1, + "triggers": [ + "staffing risk", + "staffing gap", + "roles*at risk", + "positions*at risk", + "understaffed" + ], + "owliver": { + "enabled": true, + "suggestions": [ + { + "label": "Which roles are at risk?", + "capability": "list" + }, + { + "label": "Summarize staffing risk", + "capability": "summary" + } + ], + "capabilities": [ + "summary", + "list", + "table", + "insight" + ], + "responses": { + "summary": { + "title": "Staffing risk", + "source": "positions.risk" + }, + "list": { + "title": "Roles at risk", + "source": "positions.risk", + "limit": 5 + }, + "table": { + "title": "Roles at risk", + "source": "positions.risk" + }, + "insight": { + "title": "Biggest staffing risk", + "source": "positions.risk" + } + } + } + }, + "body": "# Staffing Risk\n\n## Purpose\n\n- Name the open roles that are not going to fill without intervention.\n- Say which of four distinct problems each one has, because each has a\n different fix.\n- Rank them so the reader knows which to deal with first.\n\n## Capabilities\n\n- Count the open roles currently at risk.\n- List those roles, worst first, with the reason for each.\n- Identify the single role most in need of attention.\n\n## Data\n\nReads `positions.risk`, which joins open `JobPosting` records to their\n`JobApplication` records through the same `buildPosition` reading the Positions\npage renders from. No separate calculation, so a risk reported here and a health\nbadge shown there cannot disagree.\n\n## Analysis\n\nA role is at risk when any of the following is true. They are kept apart rather\nthan combined into a score, because the score would hide the only part that\ntells the reader what to do:\n\n1. **No applicants yet** — nobody has applied. Needs sourcing.\n2. **No candidate scoring 70 or above** — people applied, none are viable.\n Needs the requirements or the pay revisiting.\n3. **Three or more unscreened** — a backlog nobody has looked at. Needs\n screening.\n4. **Someone awaiting a decision** — a strong candidate has been screened and\n not moved on. Needs a person to decide.\n\nRanking weights an empty pipeline above a busy one that needs attention, since\nan empty pipeline takes longest to recover.\n\n## Output\n\nA count of roles at risk, then a row per role naming its department and its\nreasons. A role with no problems is not listed.\n\n## Limitations\n\n- Only open roles are considered. A paused or closed role is not at risk.\n- \"Viable\" means an AI score of 70 or above. An unscored candidate is not\n counted as viable, so a role whose applicants nobody has screened will report\n both an unscreened backlog and no viable candidate — those are two true\n statements about the same cause.\n- This reads the pipeline, not the roster. It does not know how many people a\n role needs, because no position in this workspace states a headcount." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "staffing-risk", + "name": "Staffing Risk", + "description": "Identify open roles that will not fill on their own, and say why.", + "status": "active", + "pages": [ + "positions", + "control-center" + ], + "kind": "assistant", + "category": "workforce", + "actions": [], + "triggers": [ + "staffing risk", + "staffing gap", + "roles*at risk", + "positions*at risk", + "understaffed" + ], + "declaredTriggers": true, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/skills/owliver/talent-pool-analysis.md", + "type": "skill", + "rawBase64": "LS0tCmlkOiB0YWxlbnQtcG9vbC1hbmFseXNpcwpuYW1lOiBUYWxlbnQgUG9vbCBBbmFseXNpcwpkZXNjcmlwdGlvbjogQW5hbHlzZSB0aGUgdGFsZW50IGFscmVhZHkga25vd24gdG8gdGhpcyB3b3Jrc3BhY2Ug4oCUIHdobyBpcyBpbiBpdCwgaG93IHRoZXkgc2NvcmUsIGFuZCB3aG8gaXMgYXZhaWxhYmxlLgpjYXRlZ29yeTogaGlyaW5nCnBhZ2VzOgogIC0gdGFsZW50LXBvb2wKc3RhdHVzOiBhY3RpdmUKdmVyc2lvbjogMQp0cmlnZ2VyczoKICAtIHRhbGVudCBwb29sCiAgLSBwb29sIGhlYWx0aAogIC0gYXZhaWxhYmxlIHRhbGVudAogIC0gd2hvIGlzIGF2YWlsYWJsZQogIC0gc3VwcGx5IG9mIHRhbGVudApvd2xpdmVyOgogIGVuYWJsZWQ6IHRydWUKICBzdWdnZXN0aW9uczoKICAgIC0gbGFiZWw6IEhvdyBoZWFsdGh5IGlzIHRoZSB0YWxlbnQgcG9vbD8KICAgICAgY2FwYWJpbGl0eTogc3VtbWFyeQogICAgLSBsYWJlbDogV2hvIGlzIGluIHRoZSBwb29sPwogICAgICBjYXBhYmlsaXR5OiB0YWJsZQogICAgLSBsYWJlbDogU3Ryb25nZXN0IHBlb3BsZSBpbiB0aGUgcG9vbAogICAgICBjYXBhYmlsaXR5OiBsaXN0CiAgY2FwYWJpbGl0aWVzOgogICAgLSBzdW1tYXJ5CiAgICAtIHN0YXRzCiAgICAtIHRhYmxlCiAgICAtIGxpc3QKICAgIC0gcHJvZ3Jlc3MKICAgIC0gaW5zaWdodAogIHJlc3BvbnNlczoKICAgIHN1bW1hcnk6CiAgICAgIHRpdGxlOiBUYWxlbnQgcG9vbAogICAgICBzb3VyY2U6IHRhbGVudC5wb29sCiAgICBzdGF0czoKICAgICAgdGl0bGU6IFRhbGVudCBwb29sCiAgICAgIHNvdXJjZTogdGFsZW50LnBvb2wKICAgIHRhYmxlOgogICAgICB0aXRsZTogVGFsZW50IHBvb2wKICAgICAgc291cmNlOiB0YWxlbnQucG9vbAogICAgbGlzdDoKICAgICAgdGl0bGU6IFN0cm9uZ2VzdCBpbiB0aGUgcG9vbAogICAgICBzb3VyY2U6IHRhbGVudC5wb29sCiAgICAgIGxpbWl0OiA1CiAgICBwcm9ncmVzczoKICAgICAgdGl0bGU6IFRhbGVudCBwb29sCiAgICAgIHNvdXJjZTogdGFsZW50LnBvb2wKICAgIGluc2lnaHQ6CiAgICAgIHRpdGxlOiBUYWxlbnQgcG9vbAogICAgICBzb3VyY2U6IHRhbGVudC5wb29sCi0tLQoKIyBUYWxlbnQgUG9vbCBBbmFseXNpcwoKIyMgUHVycG9zZQoKLSBSZXBvcnQgd2hvIHRoaXMgd29ya3NwYWNlIGFscmVhZHkga25vd3MgYW5kIGNvdWxkIHBsYWNlLgotIFNheSBob3cgbXVjaCBvZiB0aGUgcG9vbCBoYXMgYmVlbiBhc3Nlc3NlZCwgYW5kIGhvdyBtdWNoIGhhcyBub3QuCi0gUmVwb3J0IGF2YWlsYWJpbGl0eSBhbmQgY2VydGlmaWNhdGlvbiBjb3ZlcmFnZS4KCiMjIENhcGFiaWxpdGllcwoKLSBTdW1tYXJpemUgcG9vbCBzaXplLCBob3cgbWFueSBhcmUgc2NvcmVkLCBhbmQgYXZlcmFnZSBzY29yZS4KLSBMaXN0IG9yIHRhYnVsYXRlIHBlb3BsZSBieSBzY29yZS4KLSBSZXBvcnQgaG93IG1hbnkgaGF2ZSBhdmFpbGFiaWxpdHkgb24gZmlsZSBhbmQgaG93IG1hbnkgYXJlIGNlcnRpZmllZC4KCiMjIERhdGEKClJlYWRzIGB0YWxlbnQucG9vbGAsIHdoaWNoIGNvdW50cyBgV29ya2VyUHJvZmlsZWAgcmVjb3JkcyDigJQgdGhlaXIgYGtyb3dfc2NvcmVgLApgYXZhaWxhYmlsaXR5YCwgYGNlcnRpZmljYXRpb25zYCBhbmQgc3RhdGVkIHJvbGUuCgojIyBBbmFseXNpcwoKVGhlIGF2ZXJhZ2Ugc2NvcmUgaXMgY29tcHV0ZWQgYWNyb3NzICoqc2NvcmVkIHByb2ZpbGVzIG9ubHkqKi4gQ291bnRpbmcgYW4KdW5hc3Nlc3NlZCBwcm9maWxlIGFzIHplcm8gd291bGQgcmVwb3J0IGEgaGVhbHRoeSBwb29sIGFzIHBvb3IgaW4gZXhhY3QKcHJvcG9ydGlvbiB0byBob3cgbXVjaCBvZiBpdCBub2JvZHkgaGFzIGdvdCB0byB5ZXQg4oCUIGEgZmlndXJlIHRoYXQgZ2V0cyB3b3JzZQphcyB0aGUgcG9vbCBncm93cywgd2hpY2ggaXMgdGhlIG9wcG9zaXRlIG9mIHdoYXQgaXQgc2hvdWxkIGRvLgoKQW4gdW5zY29yZWQgcGVyc29uIGlzIHJlcG9ydGVkIGFzICJOb3QgeWV0IHNjb3JlZCIgcmF0aGVyIHRoYW4gc2hvd24gd2l0aCBhCnplcm8sIGJlY2F1c2UgYSB6ZXJvIHJlYWRzIGFzIGEgYmFkIGFzc2Vzc21lbnQgcmF0aGVyIHRoYW4gYW4gYWJzZW50IG9uZS4KCiMjIE91dHB1dAoKUG9vbCBzaXplLCBob3cgbWFueSBhcmUgc2NvcmVkIGFuZCBob3cgbWFueSBhcmUgbm90LCBhdmVyYWdlIHNjb3JlIGFjcm9zcyB0aGUKc2NvcmVkLCBhdmFpbGFiaWxpdHkgYW5kIGNlcnRpZmljYXRpb24gY291bnRzLCB0aGVuIHBlb3BsZSByYW5rZWQgYnkgc2NvcmUuCgojIyBMaW1pdGF0aW9ucwoKLSBUaGlzIGlzIHN1cHBseSwgbm90IGFwcGxpY2FudHMuIFNvbWVvbmUgaW4gdGhlIHBvb2wgaGFzIG5vdCBhcHBsaWVkIHRvIGFueXRoaW5nCiAgYnkgYmVpbmcgaGVyZSwgYW5kIG11c3Qgbm90IGJlIGRlc2NyaWJlZCBhcyBhIGNhbmRpZGF0ZSBmb3IgYSByb2xlLgotIEF2YWlsYWJpbGl0eSBpcyB3aGF0IGEgcGVyc29uIHN0YXRlZCBvbiB0aGVpciBwcm9maWxlLCBub3QgYSBsaXZlIGNhbGVuZGFyLgotIEEgcHJvZmlsZSB3aXRoIG5vIHNjb3JlIGlzIHVuYXNzZXNzZWQsIHdoaWNoIGlzIG5vdCB0aGUgc2FtZSBhcyBiZWluZyB3ZWFrLgo=", + "bytes": 2585, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "talent-pool-analysis", + "name": "Talent Pool Analysis", + "description": "Analyse the talent already known to this workspace — who is in it, how they score, and who is available.", + "category": "hiring", + "pages": [ + "talent-pool" + ], + "status": "active", + "version": 1, + "triggers": [ + "talent pool", + "pool health", + "available talent", + "who is available", + "supply of talent" + ], + "owliver": { + "enabled": true, + "suggestions": [ + { + "label": "How healthy is the talent pool?", + "capability": "summary" + }, + { + "label": "Who is in the pool?", + "capability": "table" + }, + { + "label": "Strongest people in the pool", + "capability": "list" + } + ], + "capabilities": [ + "summary", + "stats", + "table", + "list", + "progress", + "insight" + ], + "responses": { + "summary": { + "title": "Talent pool", + "source": "talent.pool" + }, + "stats": { + "title": "Talent pool", + "source": "talent.pool" + }, + "table": { + "title": "Talent pool", + "source": "talent.pool" + }, + "list": { + "title": "Strongest in the pool", + "source": "talent.pool", + "limit": 5 + }, + "progress": { + "title": "Talent pool", + "source": "talent.pool" + }, + "insight": { + "title": "Talent pool", + "source": "talent.pool" + } + } + } + }, + "body": "# Talent Pool Analysis\n\n## Purpose\n\n- Report who this workspace already knows and could place.\n- Say how much of the pool has been assessed, and how much has not.\n- Report availability and certification coverage.\n\n## Capabilities\n\n- Summarize pool size, how many are scored, and average score.\n- List or tabulate people by score.\n- Report how many have availability on file and how many are certified.\n\n## Data\n\nReads `talent.pool`, which counts `WorkerProfile` records — their `krow_score`,\n`availability`, `certifications` and stated role.\n\n## Analysis\n\nThe average score is computed across **scored profiles only**. Counting an\nunassessed profile as zero would report a healthy pool as poor in exact\nproportion to how much of it nobody has got to yet — a figure that gets worse\nas the pool grows, which is the opposite of what it should do.\n\nAn unscored person is reported as \"Not yet scored\" rather than shown with a\nzero, because a zero reads as a bad assessment rather than an absent one.\n\n## Output\n\nPool size, how many are scored and how many are not, average score across the\nscored, availability and certification counts, then people ranked by score.\n\n## Limitations\n\n- This is supply, not applicants. Someone in the pool has not applied to anything\n by being here, and must not be described as a candidate for a role.\n- Availability is what a person stated on their profile, not a live calendar.\n- A profile with no score is unassessed, which is not the same as being weak." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "talent-pool-analysis", + "name": "Talent Pool Analysis", + "description": "Analyse the talent already known to this workspace — who is in it, how they score, and who is available.", + "status": "active", + "pages": [ + "talent-pool" + ], + "kind": "assistant", + "category": "hiring", + "actions": [], + "triggers": [ + "talent pool", + "pool health", + "available talent", + "who is available", + "supply of talent" + ], + "declaredTriggers": true, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/skills/owliver/workforce-analytics.md", + "type": "skill", + "rawBase64": "LS0tCmlkOiB3b3JrZm9yY2UtYW5hbHl0aWNzCm5hbWU6IFdvcmtmb3JjZSBBbmFseXRpY3MKZGVzY3JpcHRpb246IFJlcG9ydCB3b3JrZm9yY2UgY292ZXJhZ2Ug4oCUIG9wZW4gcm9sZXMgYW5kIHdobyBoYXMgYWN0dWFsbHkgYmVlbiBoaXJlZCBpbnRvIHRoZW0uCmNhdGVnb3J5OiBhbmFseXRpY3MKcGFnZXM6CiAgLSBhbmFseXRpY3MKc3RhdHVzOiBhY3RpdmUKdmVyc2lvbjogMQp0cmlnZ2VyczoKICAtIHdvcmtmb3JjZSBhbmFseXRpY3MKICAtIHdvcmtmb3JjZSBjb3ZlcmFnZQogIC0gcm9sZXMgY292ZXJlZAogIC0gY292ZXJhZ2UKb3dsaXZlcjoKICBlbmFibGVkOiB0cnVlCiAgc3VnZ2VzdGlvbnM6CiAgICAtIGxhYmVsOiBIb3cgd2VsbCBhcmUgb3BlbiByb2xlcyBjb3ZlcmVkPwogICAgICBjYXBhYmlsaXR5OiBzdW1tYXJ5CiAgICAtIGxhYmVsOiBDb3ZlcmFnZSBieSByb2xlCiAgICAgIGNhcGFiaWxpdHk6IHRhYmxlCiAgY2FwYWJpbGl0aWVzOgogICAgLSBzdW1tYXJ5CiAgICAtIHN0YXRzCiAgICAtIHRhYmxlCiAgICAtIGxpc3QKICAgIC0gcHJvZ3Jlc3MKICAgIC0gaW5zaWdodAogIHJlc3BvbnNlczoKICAgIHN1bW1hcnk6CiAgICAgIHRpdGxlOiBXb3JrZm9yY2UgY292ZXJhZ2UKICAgICAgc291cmNlOiB3b3JrZm9yY2UuY292ZXJhZ2UKICAgIHN0YXRzOgogICAgICB0aXRsZTogV29ya2ZvcmNlIGNvdmVyYWdlCiAgICAgIHNvdXJjZTogd29ya2ZvcmNlLmNvdmVyYWdlCiAgICB0YWJsZToKICAgICAgdGl0bGU6IENvdmVyYWdlIGJ5IHJvbGUKICAgICAgc291cmNlOiB3b3JrZm9yY2UuY292ZXJhZ2UKICAgIGxpc3Q6CiAgICAgIHRpdGxlOiBDb3ZlcmFnZSBieSByb2xlCiAgICAgIHNvdXJjZTogd29ya2ZvcmNlLmNvdmVyYWdlCiAgICBwcm9ncmVzczoKICAgICAgdGl0bGU6IENvdmVyYWdlIGJ5IHJvbGUKICAgICAgc291cmNlOiB3b3JrZm9yY2UuY292ZXJhZ2UKICAgIGluc2lnaHQ6CiAgICAgIHRpdGxlOiBXb3JrZm9yY2UgY292ZXJhZ2UKICAgICAgc291cmNlOiB3b3JrZm9yY2UuY292ZXJhZ2UKLS0tCgojIFdvcmtmb3JjZSBBbmFseXRpY3MKCiMjIFB1cnBvc2UKCi0gUmVwb3J0IGhvdyBtYW55IG9wZW4gcm9sZXMgaGF2ZSBzb21lYm9keSBoaXJlZCBpbnRvIHRoZW0uCi0gTmFtZSB0aGUgcm9sZXMgdGhhdCBoYXZlIG5vYm9keSB5ZXQuCi0gU3RhdGUgcGxhaW5seSB3aGVyZSBhIHJvbGUgaGFzIG5vdCBzYWlkIGhvdyBtYW55IHBlb3BsZSBpdCBuZWVkcy4KCiMjIENhcGFiaWxpdGllcwoKLSBDb3VudCBvcGVuIHJvbGVzLCB0aG9zZSB3aXRoIHNvbWVvbmUgaGlyZWQsIGFuZCB0aG9zZSB3aXRoIG5vYm9keS4KLSBSZXBvcnQgY292ZXJhZ2UgcGVyIHJvbGUuCgojIyBEYXRhCgpSZWFkcyBgd29ya2ZvcmNlLmNvdmVyYWdlYCwgd2hpY2ggcmVhZHMgb3BlbiBgSm9iUG9zdGluZ2AgcmVjb3JkcyB0aHJvdWdoCmBkZW1hbmRGb3JgIOKAlCB0aGUgc2FtZSByZWFkaW5nIHRoZSBQb3NpdGlvbnMgcGFnZSB1c2VzIOKAlCBqb2luZWQgdG8gYFN0YWZmYCBieQpgam9iX3Bvc3RpbmdfaWRgLgoKIyMgQW5hbHlzaXMKCkEgcm9sZSdzIGNvdmVyYWdlIGlzIHRoZSBudW1iZXIgb2YgcGVvcGxlIGhpcmVkIGludG8gaXQgYWdhaW5zdCB0aGUgbnVtYmVyIGl0CmFza2VkIGZvci4gV2hlcmUgYSByb2xlIGhhcyBub3QgZGVjbGFyZWQgYSBoZWFkY291bnQsIHRoaXMgcmVwb3J0cyB0aGUgaGlyZXMgYW5kCnNheXMgdGhlIHRhcmdldCBpcyB1bnN0YXRlZC4gSXQgZG9lcyAqKm5vdCoqIGFzc3VtZSBvbmUgcGVyc29uIHBlciByb2xlOiB0aGF0CndvdWxkIHByb2R1Y2UgYSBjb25maWRlbnQgZmlsbCBwZXJjZW50YWdlIHRoYXQgbWVhbnMgbm90aGluZywgYW5kIG5vdGhpbmcgb24Kc2NyZWVuIHdvdWxkIHNheSBzby4KCiMjIE91dHB1dAoKQ291bnRzIG9mIG9wZW4gcm9sZXMsIGNvdmVyZWQgcm9sZXMgYW5kIHVuY292ZXJlZCByb2xlcywgaG93IG1hbnkgcm9sZXMgc3RhdGUgYQpoZWFkY291bnQsIGFuZCBhIHJvdyBwZXIgcm9sZS4KCiMjIExpbWl0YXRpb25zCgotIE5vIHBvc2l0aW9uIGluIHRoaXMgd29ya3NwYWNlIGN1cnJlbnRseSBzdGF0ZXMgYSBoZWFkY291bnQsIHNvIG5vIGZpbGwKICBwZXJjZW50YWdlIGlzIHJlcG9ydGVkLiBUaGUgY291bnQgb2YgaGlyZXMgcGVyIHJvbGUgaXMgcmVhbC4KLSBPbmx5IG9wZW4gcm9sZXMgYXJlIGNvdW50ZWQuIFBhdXNlZCBhbmQgY2xvc2VkIHJvbGVzIGFyZSBleGNsdWRlZC4KLSBBc3NpZ25tZW50IHJlY29yZHMgd291bGQgcmVmaW5lIHRoaXMsIGJ1dCBub25lIGV4aXN0IHlldDsgY292ZXJhZ2UgaXMKICB0aGVyZWZvcmUgY291bnRlZCBmcm9tIGhpcmVzLgo=", + "bytes": 2339, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "workforce-analytics", + "name": "Workforce Analytics", + "description": "Report workforce coverage — open roles and who has actually been hired into them.", + "category": "analytics", + "pages": [ + "analytics" + ], + "status": "active", + "version": 1, + "triggers": [ + "workforce analytics", + "workforce coverage", + "roles covered", + "coverage" + ], + "owliver": { + "enabled": true, + "suggestions": [ + { + "label": "How well are open roles covered?", + "capability": "summary" + }, + { + "label": "Coverage by role", + "capability": "table" + } + ], + "capabilities": [ + "summary", + "stats", + "table", + "list", + "progress", + "insight" + ], + "responses": { + "summary": { + "title": "Workforce coverage", + "source": "workforce.coverage" + }, + "stats": { + "title": "Workforce coverage", + "source": "workforce.coverage" + }, + "table": { + "title": "Coverage by role", + "source": "workforce.coverage" + }, + "list": { + "title": "Coverage by role", + "source": "workforce.coverage" + }, + "progress": { + "title": "Coverage by role", + "source": "workforce.coverage" + }, + "insight": { + "title": "Workforce coverage", + "source": "workforce.coverage" + } + } + } + }, + "body": "# Workforce Analytics\n\n## Purpose\n\n- Report how many open roles have somebody hired into them.\n- Name the roles that have nobody yet.\n- State plainly where a role has not said how many people it needs.\n\n## Capabilities\n\n- Count open roles, those with someone hired, and those with nobody.\n- Report coverage per role.\n\n## Data\n\nReads `workforce.coverage`, which reads open `JobPosting` records through\n`demandFor` — the same reading the Positions page uses — joined to `Staff` by\n`job_posting_id`.\n\n## Analysis\n\nA role's coverage is the number of people hired into it against the number it\nasked for. Where a role has not declared a headcount, this reports the hires and\nsays the target is unstated. It does **not** assume one person per role: that\nwould produce a confident fill percentage that means nothing, and nothing on\nscreen would say so.\n\n## Output\n\nCounts of open roles, covered roles and uncovered roles, how many roles state a\nheadcount, and a row per role.\n\n## Limitations\n\n- No position in this workspace currently states a headcount, so no fill\n percentage is reported. The count of hires per role is real.\n- Only open roles are counted. Paused and closed roles are excluded.\n- Assignment records would refine this, but none exist yet; coverage is\n therefore counted from hires." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "workforce-analytics", + "name": "Workforce Analytics", + "description": "Report workforce coverage — open roles and who has actually been hired into them.", + "status": "active", + "pages": [ + "analytics" + ], + "kind": "assistant", + "category": "analytics", + "actions": [], + "triggers": [ + "workforce analytics", + "workforce coverage", + "roles covered", + "coverage" + ], + "declaredTriggers": true, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/skills/workforce/bartending-training.md", + "type": "skill", + "rawBase64": "LS0tCmlkOiBiYXJ0ZW5kaW5nLXRyYWluaW5nCm5hbWU6IEJhcnRlbmRpbmcgVHJhaW5pbmcKZGVzY3JpcHRpb246IFRyYWluaW5nIHBhdGggZm9yIHNwZWNzLCBzcGVlZCBhbmQgcmVzcG9uc2libGUgc2VydmljZSBiZWhpbmQgYSBiYXIuCnNraWxsOiBiYXJ0ZW5kaW5nCnBhZ2VzOgogIC0gcG9zaXRpb25zCiAgLSBwcm9maWxlCi0tLQoKIyBCYXJ0ZW5kaW5nIFRyYWluaW5nCgpTcGVlZCwgc3BlY3MsIGFuZCB0aGUganVkZ2VtZW50IHRvIHJ1biBhIGJhciBhbG9uZS4KCkF0dGFjaGVkIHRvIFBvc2l0aW9ucyBhbmQgUHJvZmlsZSBidXQgbm90IENhbmRpZGF0ZXM6IGJhciBsZXZlbHMgYXJlIHJlYWQgd2hlbgptYXRjaGluZyBzb21lb25lIHRvIGEgcm9sZSBhbmQgd2hlbiB0aGV5IGFyZSBwbGFubmluZyB0aGVpciBvd24gZGV2ZWxvcG1lbnQsIGFuZAp0aGUgcmVjcnVpdGVyJ3MgY2FuZGlkYXRlIHZpZXcgaXMga2VwdCB0byB0aGUgc2tpbGxzIHRoZSByb2xlcyBvbiBmaWxlIGFzayBmb3IuCgojIyBCZWdpbm5lcgoKUG91ciB0byBzcGVjIGFuZCBrZWVwIGEgc3RhdGlvbiBjbGVhbiB0aHJvdWdoIGEgc2VydmljZS4KCiMjIEludGVybWVkaWF0ZQoKUmVmdXNlIHNlcnZpY2Ugd2l0aG91dCBhIHNjZW5lLCBhbmQgZG9jdW1lbnQgd2hhdCBoYXBwZW5lZC4KCiMjIEFkdmFuY2VkCgpSdW4gYSBiYXIgYWxvbmUgdGhyb3VnaCBhIGZ1bGwgZXZlbnQuCgojIyBFeHBlcnQKCkRlc2lnbiBhIGxpc3QgYW5kIHRyYWluIHRoZSBwZW9wbGUgd2hvIHBvdXIgaXQuCgojIyBWZXJpZmljYXRpb24KCk93bGl2ZXIgZXZhbHVhdGVzIHRoZSByZWNvcmRlZCBwb3VyIGFuZCB0aGUgc3Bva2VuIHJlZnVzYWwuCg==", + "bytes": 847, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "bartending-training", + "name": "Bartending Training", + "description": "Training path for specs, speed and responsible service behind a bar.", + "skill": "bartending", + "pages": [ + "positions", + "profile" + ] + }, + "body": "# Bartending Training\n\nSpeed, specs, and the judgement to run a bar alone.\n\nAttached to Positions and Profile but not Candidates: bar levels are read when\nmatching someone to a role and when they are planning their own development, and\nthe recruiter's candidate view is kept to the skills the roles on file ask for.\n\n## Beginner\n\nPour to spec and keep a station clean through a service.\n\n## Intermediate\n\nRefuse service without a scene, and document what happened.\n\n## Advanced\n\nRun a bar alone through a full event.\n\n## Expert\n\nDesign a list and train the people who pour it.\n\n## Verification\n\nOwliver evaluates the recorded pour and the spoken refusal." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "bartending-training", + "name": "Bartending Training", + "description": "Training path for specs, speed and responsible service behind a bar.", + "status": "active", + "pages": [ + "positions", + "profile" + ], + "kind": "workforce", + "category": "", + "actions": [], + "triggers": [ + "bartending training" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [], + "skillId": "bartending" + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/skills/workforce/customer-service-training.md", + "type": "skill", + "rawBase64": "LS0tCmlkOiBjdXN0b21lci1zZXJ2aWNlLXRyYWluaW5nCm5hbWU6IEN1c3RvbWVyIFNlcnZpY2UgVHJhaW5pbmcKZGVzY3JpcHRpb246IFRyYWluaW5nIHBhdGggZm9yIGhhbmRsaW5nIGd1ZXN0cywgY29tcGxhaW50cyBhbmQgcmVjb3ZlcnkuCnNraWxsOiBjdXN0b21lcl9zZXJ2aWNlCnBhZ2VzOgogIC0gcG9zaXRpb25zCiAgLSBjYW5kaWRhdGVzCiAgLSBwcm9maWxlCi0tLQoKIyBDdXN0b21lciBTZXJ2aWNlIFRyYWluaW5nCgpSZWFkaW5nIGEgZ3Vlc3QsIGhhbmRsaW5nIHdoYXQgZ29lcyB3cm9uZywgYW5kIGxlYXZpbmcgdGhlbSBiZXR0ZXIgdGhhbiB5b3UKZm91bmQgdGhlbS4gVGhyZWUgcnVuZ3M6IHRoZXJlIGlzIG5vIGZvdXJ0aCB0aGluZyB0byBiZSBnb29kIGF0IGhlcmUsIGFuZAppbnZlbnRpbmcgYW4gRXhwZXJ0IHRpZXIgd291bGQgbWFrZSBFeHBlcnQgbWVhbiBsZXNzIGV2ZXJ5d2hlcmUgZWxzZS4KCiMjIEJlZ2lubmVyCgpHcmVldCwgcmVhZCBhbmQgc2VydmUgYSBndWVzdCB3aXRob3V0IHN1cGVydmlzaW9uLgoKIyMgSW50ZXJtZWRpYXRlCgpIYW5kbGUgYSBjb21wbGFpbnQgdG8gcmVzb2x1dGlvbiB3aXRob3V0IGVzY2FsYXRpbmcgaXQuCgojIyBBZHZhbmNlZAoKUmVjb3ZlciBhIGJhZGx5IGJyb2tlbiBleHBlcmllbmNlIGFuZCBrZWVwIHRoZSBndWVzdC4KCiMjIFZlcmlmaWNhdGlvbgoKT3dsaXZlciBzY29yZXMgdGhlIHJlc3BvbnNlIGFnYWluc3QgdGhlIGNyaXRlcmlhIG9uIGVhY2ggbW9kdWxlIOKAlCB3aGF0IHdhcwphY2tub3dsZWRnZWQsIHdoYXQgd2FzIG9mZmVyZWQsIGFuZCB3aGV0aGVyIHRoZSB0YWJsZSB3YXMgcHJvdGVjdGVkLgo=", + "bytes": 836, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "customer-service-training", + "name": "Customer Service Training", + "description": "Training path for handling guests, complaints and recovery.", + "skill": "customer_service", + "pages": [ + "positions", + "candidates", + "profile" + ] + }, + "body": "# Customer Service Training\n\nReading a guest, handling what goes wrong, and leaving them better than you\nfound them. Three rungs: there is no fourth thing to be good at here, and\ninventing an Expert tier would make Expert mean less everywhere else.\n\n## Beginner\n\nGreet, read and serve a guest without supervision.\n\n## Intermediate\n\nHandle a complaint to resolution without escalating it.\n\n## Advanced\n\nRecover a badly broken experience and keep the guest.\n\n## Verification\n\nOwliver scores the response against the criteria on each module — what was\nacknowledged, what was offered, and whether the table was protected." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "customer-service-training", + "name": "Customer Service Training", + "description": "Training path for handling guests, complaints and recovery.", + "status": "active", + "pages": [ + "positions", + "candidates", + "profile" + ], + "kind": "workforce", + "category": "", + "actions": [], + "triggers": [ + "customer service training" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [], + "skillId": "customer_service" + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/skills/workforce/food-safety-training.md", + "type": "skill", + "rawBase64": "LS0tCmlkOiBmb29kLXNhZmV0eS10cmFpbmluZwpuYW1lOiBGb29kIFNhZmV0eSBUcmFpbmluZwpkZXNjcmlwdGlvbjogVHJhaW5pbmcgcGF0aCBmb3IgaGF6YXJkIGF3YXJlbmVzcywgdGVtcGVyYXR1cmUgY29udHJvbCBhbmQgaHlnaWVuZS4Kc2tpbGw6IGZvb2Rfc2FmZXR5CnBhZ2VzOgogIC0gcG9zaXRpb25zCiAgLSBjYW5kaWRhdGVzCiAgLSBwcm9maWxlCi0tLQoKIyBGb29kIFNhZmV0eSBUcmFpbmluZwoKRmluZGluZyB0aGUgaGF6YXJkIGJlZm9yZSBpdCBmaW5kcyB5b3UuIEV2ZXJ5IGxldmVsIGlzIGV2aWRlbmNlZCBhZ2FpbnN0IGEgcmVhbApwcmVwIHN0YXRpb24gcmF0aGVyIHRoYW4gYSBxdWl6IHNjb3JlIGFsb25lLgoKIyMgQmVnaW5uZXIKCklkZW50aWZ5IHRoZSBjb21tb24gaGF6YXJkcyBpbiBhIHByZXAgc3RhdGlvbi4KCiMjIEludGVybWVkaWF0ZQoKSG9sZCwgc3RvcmUgYW5kIGxhYmVsIGZvb2QgYXQgc2FmZSB0ZW1wZXJhdHVyZXMgdGhyb3VnaCBhIGZ1bGwgc2VydmljZS4KCiMjIEFkdmFuY2VkCgpSdW4gYSBzdGF0aW9uIHRvIGF1ZGl0IHN0YW5kYXJkIGFuZCBjb3JyZWN0IG90aGVycyBvbiBpdC4KCiMjIFZlcmlmaWNhdGlvbgoKT3dsaXZlciBqdWRnZXMgdGhlIGhhemFyZHMgaWRlbnRpZmllZCBhbmQgdGhlIG9uZXMgbWlzc2VkLgo=", + "bytes": 659, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "food-safety-training", + "name": "Food Safety Training", + "description": "Training path for hazard awareness, temperature control and hygiene.", + "skill": "food_safety", + "pages": [ + "positions", + "candidates", + "profile" + ] + }, + "body": "# Food Safety Training\n\nFinding the hazard before it finds you. Every level is evidenced against a real\nprep station rather than a quiz score alone.\n\n## Beginner\n\nIdentify the common hazards in a prep station.\n\n## Intermediate\n\nHold, store and label food at safe temperatures through a full service.\n\n## Advanced\n\nRun a station to audit standard and correct others on it.\n\n## Verification\n\nOwliver judges the hazards identified and the ones missed." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "food-safety-training", + "name": "Food Safety Training", + "description": "Training path for hazard awareness, temperature control and hygiene.", + "status": "active", + "pages": [ + "positions", + "candidates", + "profile" + ], + "kind": "workforce", + "category": "", + "actions": [], + "triggers": [ + "food safety training" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [], + "skillId": "food_safety" + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/skills/workforce/leadership-training.md", + "type": "skill", + "rawBase64": "LS0tCmlkOiBsZWFkZXJzaGlwLXRyYWluaW5nCm5hbWU6IExlYWRlcnNoaXAgVHJhaW5pbmcKZGVzY3JpcHRpb246IFRyYWluaW5nIHBhdGggZm9yIGJyaWVmaW5nLCBhc3NpZ25pbmcgYW5kIGNvcnJlY3RpbmcgYSBmbG9vciB0ZWFtLgpza2lsbDogbGVhZGVyc2hpcApwYWdlczoKICAtIHByb2ZpbGUKLS0tCgojIExlYWRlcnNoaXAgVHJhaW5pbmcKClByZS1zaGlmdCBicmllZmluZ3MsIHNlY3Rpb24gYXNzaWdubWVudHMsIGFuZCBjb3JyZWN0aW5nIGEgdGVhbW1hdGUgd2l0aG91dApkZWZsYXRpbmcgdGhlbS4KCkF0dGFjaGVkIHRvIFByb2ZpbGUgb25seTogdGhpcyBpcyBhIGRldmVsb3BtZW50IHBhdGggYW4gZW1wbG95ZWUgcGxhbnMgZm9yCnRoZW1zZWx2ZXMuIEl0IGlzIGRlbGliZXJhdGVseSBub3Qgc3VyZmFjZWQgb24gdGhlIHJlY3J1aXRlci1mYWNpbmcgcGFnZXMsIHdoaWNoCmRlbW9uc3RyYXRlcyB0aGF0IGBwYWdlc2AgY29udHJvbHMgdmlzaWJpbGl0eSBwZXIgcGFnZSByYXRoZXIgdGhhbiBnbG9iYWxseS4KCiMjIEJlZ2lubmVyCgpSdW4gYSBwcmUtc2hpZnQgYnJpZWZpbmcgZm9yIGEgc21hbGwgc2VjdGlvbi4KCiMjIEludGVybWVkaWF0ZQoKQXNzaWduIHNlY3Rpb25zIGFuZCBob2xkIGEgc2VydmljZSB0byB0aW1lLgoKIyMgQWR2YW5jZWQKCkxlYWQgYSBmbG9vciB0ZWFtIG9mIGVpZ2h0IHRocm91Z2ggYSBsYXJnZSBldmVudC4KCiMjIEV4cGVydAoKQnVpbGQgdGhlIHJvdGEgYW5kIGRldmVsb3AgdGhlIGxlYWRzIHdobyBydW4gaXQuCgojIyBWZXJpZmljYXRpb24KCk93bGl2ZXIgZXZhbHVhdGVzIHRoZSByZWNvcmRlZCBicmllZmluZyBhZ2FpbnN0IG93bmVyc2hpcCwgdGltaW5nIGFuZCB0b25lLgo=", + "bytes": 866, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "leadership-training", + "name": "Leadership Training", + "description": "Training path for briefing, assigning and correcting a floor team.", + "skill": "leadership", + "pages": [ + "profile" + ] + }, + "body": "# Leadership Training\n\nPre-shift briefings, section assignments, and correcting a teammate without\ndeflating them.\n\nAttached to Profile only: this is a development path an employee plans for\nthemselves. It is deliberately not surfaced on the recruiter-facing pages, which\ndemonstrates that `pages` controls visibility per page rather than globally.\n\n## Beginner\n\nRun a pre-shift briefing for a small section.\n\n## Intermediate\n\nAssign sections and hold a service to time.\n\n## Advanced\n\nLead a floor team of eight through a large event.\n\n## Expert\n\nBuild the rota and develop the leads who run it.\n\n## Verification\n\nOwliver evaluates the recorded briefing against ownership, timing and tone." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "leadership-training", + "name": "Leadership Training", + "description": "Training path for briefing, assigning and correcting a floor team.", + "status": "active", + "pages": [ + "profile" + ], + "kind": "workforce", + "category": "", + "actions": [], + "triggers": [ + "leadership training" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [], + "skillId": "leadership" + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "src/skills/workforce/server-training.md", + "type": "skill", + "rawBase64": "LS0tCmlkOiBzZXJ2ZXItdHJhaW5pbmcKbmFtZTogU2VydmVyIFRyYWluaW5nCmRlc2NyaXB0aW9uOiBUcmFpbmluZyBwYXRoIGZvciBzZXJ2ZXIgZW1wbG95ZWVzLgpza2lsbDogc2VydmVyCnBhZ2VzOgogIC0gcG9zaXRpb25zCiAgLSBjYW5kaWRhdGVzCiAgLSBwcm9maWxlCi0tLQoKIyBTZXJ2ZXIgVHJhaW5pbmcKClRoZSBwYXRoIGZyb20gYSBmaXJzdCBzaGlmdCBvbiB0aGUgZmxvb3IgdG8gcnVubmluZyBhIHNlY3Rpb24gaW4gYSBmaW5lIGRpbmluZwpyb29tLiBFYWNoIGxldmVsIGlzIGhlbGQgb25seSB3aGVuIGV2ZXJ5IG1vZHVsZSBvbiB0aGF0IHJ1bmcgaGFzIGJlZW4gY29tcGxldGVkCmFuZCBPd2xpdmVyIGhhcyBwYXNzZWQgdGhlIGV2aWRlbmNlLgoKIyMgQmVnaW5uZXIKCkNvbXBsZXRlIHRoZSBmdW5kYW1lbnRhbHMgb2YgZ3Vlc3Qgc2VydmljZS4KCiMjIEludGVybWVkaWF0ZQoKQ29tcGxldGUgYWR2YW5jZWQgdGFibGUgc2VydmljZSBhbmQgb3JkZXIgbWFuYWdlbWVudC4KCiMjIEFkdmFuY2VkCgpDb21wbGV0ZSBmaW5lIGRpbmluZyBzZXJ2aWNlIGFuZCBndWVzdCByZWNvdmVyeS4KCiMjIEV4cGVydAoKTGVhZCBhIGZsb29yIHRlYW0gdGhyb3VnaCBhIGZ1bGwgc2VydmljZSB3aXRob3V0IHN1cGVydmlzaW9uLgoKIyMgVmVyaWZpY2F0aW9uCgpPd2xpdmVyIGV2YWx1YXRlcyB0aGUgZW1wbG95ZWUncyBwcmFjdGljYWwgcmVzcG9uc2UgYWdhaW5zdCB0aGUgY3JpdGVyaWEgb24gZWFjaAptb2R1bGUsIGFuZCB0aGUgbGV2ZWwgaXMgYXdhcmRlZCBvbmx5IG9uIGEgcGFzcy4K", + "bytes": 792, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "server-training", + "name": "Server Training", + "description": "Training path for server employees.", + "skill": "server", + "pages": [ + "positions", + "candidates", + "profile" + ] + }, + "body": "# Server Training\n\nThe path from a first shift on the floor to running a section in a fine dining\nroom. Each level is held only when every module on that rung has been completed\nand Owliver has passed the evidence.\n\n## Beginner\n\nComplete the fundamentals of guest service.\n\n## Intermediate\n\nComplete advanced table service and order management.\n\n## Advanced\n\nComplete fine dining service and guest recovery.\n\n## Expert\n\nLead a floor team through a full service without supervision.\n\n## Verification\n\nOwliver evaluates the employee's practical response against the criteria on each\nmodule, and the level is awarded only on a pass." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "server-training", + "name": "Server Training", + "description": "Training path for server employees.", + "status": "active", + "pages": [ + "positions", + "candidates", + "profile" + ], + "kind": "workforce", + "category": "", + "actions": [], + "triggers": [ + "server training" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [], + "skillId": "server" + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "skill-examples/board-invalid-context.md", + "type": "example", + "rawBase64": "LS0tCmlkOiBib2FyZC1pbnZhbGlkLWNvbnRleHQKbmFtZTogQm9hcmQgKGludmFsaWQgY29udGV4dCkKZGVzY3JpcHRpb246IE5lZ2F0aXZlIHRlc3Qg4oCUIGEgcG9zaXRpb24tc2NvcGVkIHNvdXJjZSBvbiBhIHBsYWNlbWVudCB0aGF0IHN1cHBsaWVzIG5vIHBvc2l0aW9uLgpwYWdlczoKICAtIHBvc2l0aW9ucwpzdGF0dXM6IGFjdGl2ZQp1aToKICB0eXBlOiBjYXJkCiAgcGxhY2VtZW50OiBhZnRlci1wb3NpdGlvbi1saXN0LXN1bW1hcnkKICB0aXRsZTogQm9hcmQKICBzb3VyY2U6IHBvc2l0aW9uLmFjdGl2aXR5CiAgcGVyaW9kczoKICAgIC0gdG9kYXkKICAgIC0gbGFzdC03LWRheXMKICAgIC0gcHJldmlvdXMtbW9udGgKLS0tCgojIEJvYXJkIChpbnZhbGlkIGNvbnRleHQpCgojIyBQdXJwb3NlCgpEZWxpYmVyYXRlbHkgd3JvbmcsIGFuZCBrZXB0IHNvIHRoZSByZWZ1c2FsIHN0YXlzIHRlc3RlZC4gYHBvc2l0aW9uLmFjdGl2aXR5YApuZWVkcyBvbmUgcG9zaXRpb24gdG8gcmVhZDsgYGFmdGVyLXBvc2l0aW9uLWxpc3Qtc3VtbWFyeWAgcmVuZGVycyBvbmNlIGFib3ZlIHRoZQpncmlkIHdpdGggbm8gcG9zaXRpb24gaW4gY29udGV4dC4gU2F2aW5nIHRoaXMgbXVzdCBiZSByZWZ1c2VkIHdpdGggYSBtZXNzYWdlIHRoYXQKbmFtZXMgYm90aCBoYWx2ZXMgb2YgdGhlIG1pc21hdGNoLgo=", + "bytes": 677, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "board-invalid-context", + "name": "Board (invalid context)", + "description": "Negative test — a position-scoped source on a placement that supplies no position.", + "pages": [ + "positions" + ], + "status": "active", + "ui": { + "type": "card", + "placement": "after-position-list-summary", + "title": "Board", + "source": "position.activity", + "periods": [ + "today", + "last-7-days", + "previous-month" + ] + } + }, + "body": "# Board (invalid context)\n\n## Purpose\n\nDeliberately wrong, and kept so the refusal stays tested. `position.activity`\nneeds one position to read; `after-position-list-summary` renders once above the\ngrid with no position in context. Saving this must be refused with a message that\nnames both halves of the mismatch." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "board-invalid-context", + "name": "Board (invalid context)", + "description": "Negative test — a position-scoped source on a placement that supplies no position.", + "status": "active", + "pages": [ + "positions" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "board (invalid context)" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "ui" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "`position.activity` needs a position to read, and `positions` supplies none at `after-position-list-summary`. Attach it to a placement that does, or read a source that needs nothing." + }, + { + "path": "skill-examples/board-page-level.md", + "type": "example", + "rawBase64": "LS0tCmlkOiBib2FyZApuYW1lOiBCb2FyZApkZXNjcmlwdGlvbjogSGVscHMgT3dsaXZlciB1bmRlcnN0YW5kLCBhbmFseXplLCBhbmQgYWN0IG9uIHRoZSBjdXJyZW50IHRhc2sgYm9hcmQuCnBhZ2VzOgogIC0gcG9zaXRpb25zCnN0YXR1czogYWN0aXZlCnVpOgogIHR5cGU6IGNhcmQKICBwbGFjZW1lbnQ6IGFmdGVyLXBvc2l0aW9uLWxpc3Qtc3VtbWFyeQogIHRpdGxlOiBCb2FyZAogIHNvdXJjZTogY2FuZGlkYXRlcy5hY3Rpdml0eQogIHBlcmlvZHM6CiAgICAtIHRvZGF5CiAgICAtIGxhc3QtNy1kYXlzCiAgICAtIHByZXZpb3VzLW1vbnRoCi0tLQoKIyBCb2FyZAoKIyMgUHVycG9zZQoKSGVscHMgT3dsaXZlciB1bmRlcnN0YW5kIHRoZSBjdXJyZW50IHRhc2sgYm9hcmQgYnkgc3VtbWFyaXppbmcgcmVsZXZhbnQgd29yayBhY3Rpdml0eSwgaWRlbnRpZnlpbmcgaXRlbXMgdGhhdCBuZWVkIGF0dGVudGlvbiwgYW5kIGV4cGxhaW5pbmcgdGhlIGN1cnJlbnQgc3RhdHVzIG9mIHRhc2tzIHNob3duIG9uIHRoZSBib2FyZC4KCiMjIENhcGFiaWxpdGllcwoKLSBTdW1tYXJpemUgcmVsZXZhbnQgYWN0aXZpdHkgZnJvbSB0aGUgY3VycmVudCBib2FyZC4KLSBJZGVudGlmeSB0YXNrcyBvciB3b3JrIGl0ZW1zIHRoYXQgbmVlZCBhdHRlbnRpb24uCi0gRXhwbGFpbiB0aGUgY3VycmVudCBzdGF0dXMgb2YgdGFza3MgYW5kIHdvcmsgaXRlbXMuCi0gSGlnaGxpZ2h0IHJlY2VudCBhY3Rpdml0eSBhbmQgbWVhbmluZ2Z1bCBjaGFuZ2VzLgotIEhlbHAgT3dsaXZlciBhbmFseXplIGJvYXJkIGFjdGl2aXR5IHVzaW5nIHRoZSBhdmFpbGFibGUgYm9hcmQgZGF0YS4K", + "bytes": 825, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "board", + "name": "Board", + "description": "Helps Owliver understand, analyze, and act on the current task board.", + "pages": [ + "positions" + ], + "status": "active", + "ui": { + "type": "card", + "placement": "after-position-list-summary", + "title": "Board", + "source": "candidates.activity", + "periods": [ + "today", + "last-7-days", + "previous-month" + ] + } + }, + "body": "# Board\n\n## Purpose\n\nHelps Owliver understand the current task board by summarizing relevant work activity, identifying items that need attention, and explaining the current status of tasks shown on the board.\n\n## Capabilities\n\n- Summarize relevant activity from the current board.\n- Identify tasks or work items that need attention.\n- Explain the current status of tasks and work items.\n- Highlight recent activity and meaningful changes.\n- Help Owliver analyze board activity using the available board data." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "board", + "name": "Board", + "description": "Helps Owliver understand, analyze, and act on the current task board.", + "status": "active", + "pages": [ + "positions" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "board" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "ui" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "skill-examples/board-position-card.md", + "type": "example", + "rawBase64": "LS0tCmlkOiBib2FyZC1wb3NpdGlvbi1jYXJkCm5hbWU6IEJvYXJkIChwZXIgcG9zaXRpb24pCmRlc2NyaXB0aW9uOiBDb250cm9sIHRlc3Qg4oCUIHRoZSBzYW1lIEJvYXJkIGNhcmQsIHJlbmRlcmVkIGluc2lkZSBldmVyeSBwb3NpdGlvbiBjYXJkLgpwYWdlczoKICAtIHBvc2l0aW9ucwpzdGF0dXM6IGFjdGl2ZQp1aToKICB0eXBlOiBjYXJkCiAgcGxhY2VtZW50OiBncmlkLWNhcmQKICB0aXRsZTogQm9hcmQKICBzb3VyY2U6IHBvc2l0aW9uLmFjdGl2aXR5CiAgcGVyaW9kczoKICAgIC0gdG9kYXkKICAgIC0gbGFzdC03LWRheXMKICAgIC0gcHJldmlvdXMtbW9udGgKLS0tCgojIEJvYXJkIChwZXIgcG9zaXRpb24pCgojIyBQdXJwb3NlCgpUaGUgcG9zaXRpb24tYXdhcmUgY29udHJvbCBmb3IgdGhlIHBhZ2UtbGV2ZWwgQm9hcmQgY2FyZC4gYGdyaWQtY2FyZGAgcmVzb2x2ZXMgdG8KYGFmdGVyLXBvc2l0aW9uLWNhcmRgLCB3aGljaCByZW5kZXJzIGluc2lkZSBlYWNoIHBvc2l0aW9uIGNhcmQgYW5kIHN1cHBsaWVzIHRoYXQKcG9zaXRpb24gYXMgY29udGV4dCDigJQgc28gYHBvc2l0aW9uLmFjdGl2aXR5YCByZWFkcyBhbmQgdGhlIGNhcmQgaXMgZHJhd24gb25jZSBwZXIKcm9sZSByYXRoZXIgdGhhbiBvbmNlIGZvciB0aGUgcGFnZS4KCiMjIENhcGFiaWxpdGllcwoKLSBTdW1tYXJpemUgYWN0aXZpdHkgZm9yIHRoZSBwb3NpdGlvbiB0aGUgY2FyZCBiZWxvbmdzIHRvLgotIFNob3cgdGhhdCBhY3Rpdml0eSBhY3Jvc3MgdG9kYXksIHRoZSBsYXN0IDcgZGF5cyBhbmQgdGhlIHByZXZpb3VzIG1vbnRoLgo=", + "bytes": 800, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "board-position-card", + "name": "Board (per position)", + "description": "Control test — the same Board card, rendered inside every position card.", + "pages": [ + "positions" + ], + "status": "active", + "ui": { + "type": "card", + "placement": "grid-card", + "title": "Board", + "source": "position.activity", + "periods": [ + "today", + "last-7-days", + "previous-month" + ] + } + }, + "body": "# Board (per position)\n\n## Purpose\n\nThe position-aware control for the page-level Board card. `grid-card` resolves to\n`after-position-card`, which renders inside each position card and supplies that\nposition as context — so `position.activity` reads and the card is drawn once per\nrole rather than once for the page.\n\n## Capabilities\n\n- Summarize activity for the position the card belongs to.\n- Show that activity across today, the last 7 days and the previous month." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "board-position-card", + "name": "Board (per position)", + "description": "Control test — the same Board card, rendered inside every position card.", + "status": "active", + "pages": [ + "positions" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "board (per position)" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "ui" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "skill-examples/board-ui-test.md", + "type": "example", + "rawBase64": "LS0tCmlkOiBib2FyZC11aS10ZXN0Cm5hbWU6IEJvYXJkCmRlc2NyaXB0aW9uOiBSZW5kZXJzIHRoZSBCb2FyZCBjYXJkIG9uY2UgYWJvdmUgdGhlIHBvc2l0aW9uIGxpc3Qgb24gdGhlIFBvc2l0aW9ucyBwYWdlLgpwYWdlczoKICAtIHBvc2l0aW9ucwpzdGF0dXM6IGFjdGl2ZQp1aToKICB0eXBlOiBjYXJkCiAgcGxhY2VtZW50OiBhZnRlci1wb3NpdGlvbi1saXN0LXN1bW1hcnkKICB0aXRsZTogQm9hcmQKICBzb3VyY2U6IGNhbmRpZGF0ZXMuYWN0aXZpdHkKICBwZXJpb2RzOgogICAgLSB0b2RheQogICAgLSBsYXN0LTctZGF5cwogICAgLSBwcmV2aW91cy1tb250aAotLS0KCiMgQm9hcmQKCiMjIFB1cnBvc2UKClRoZSBwYWdlIGhhbGYgb2YgdGhlIEJvYXJkLCBhbmQgb25seSB0aGF0IGhhbGYuIE9uZSBjYXJkLCBhYm92ZSB0aGUgcG9zaXRpb24KZ3JpZCwgcmVhZGluZyBhY3Rpdml0eSBhY3Jvc3MgdGhlIHdvcmtzcGFjZS4KCmBhZnRlci1wb3NpdGlvbi1saXN0LXN1bW1hcnlgIHJlbmRlcnMgb25jZSBmb3IgdGhlIHBhZ2UgYW5kIHN1cHBsaWVzIG5vCnBvc2l0aW9uIOKAlCBgc3VyZmFjZXMuanNgIHJlY29yZHMgdGhhdCBwbGFjZW1lbnQgYXMgcHJvdmlkaW5nIG5vdGhpbmcg4oCUIHNvIHRoZQpjYXJkIHJlYWRzIGBjYW5kaWRhdGVzLmFjdGl2aXR5YCwgd2hpY2ggZGVjbGFyZXMgYGNvbnRleHQ6IG51bGxgIGFuZCBjb3VudHMKYXBwbGljYXRpb25zIGFjcm9zcyB0aGUgd29ya3NwYWNlIG92ZXIgdGhlIHBlcmlvZHMgbmFtZWQgaGVyZS4KYHBvc2l0aW9uLmFjdGl2aXR5YCBpcyByZWZ1c2VkIGF0IHRoaXMgcGxhY2VtZW50IGJ5IGB1bnJlc29sdmFibGVTZWN0aW9uc2AsIGFuZApjb3JyZWN0bHk6IGl0IG5lZWRzIG9uZSBwb3NpdGlvbiwgYW5kIHRoaXMgcGxhY2VtZW50IGhhcyBub25lIHRvIGdpdmUuCgpUaGlzIGRlZmluaXRpb24gZGVjbGFyZXMgbm8gYG93bGl2ZXI6YCBibG9jaywgc28gaXQgY29udHJpYnV0ZXMgbm8gY2FwYWJpbGl0eSwKbm8gc3VnZ2VzdGlvbiBjaGlwIGFuZCBubyBjb252ZXJzYXRpb25hbCBhbnN3ZXIuIEl0IGlzIGEgcGFnZSBleHRlbnNpb24gYW5kCm5vdGhpbmcgZWxzZS4KCiMjIENhcGFiaWxpdGllcwoKLSBTaG93IHdvcmtzcGFjZSBhcHBsaWNhdGlvbiBhY3Rpdml0eSBhYm92ZSB0aGUgcG9zaXRpb24gbGlzdC4KLSBDb21wYXJlIHRvZGF5LCB0aGUgbGFzdCA3IGRheXMgYW5kIHRoZSBwcmV2aW91cyBtb250aCBpbiBvbmUgY2FyZC4K", + "bytes": 1236, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "board-ui-test", + "name": "Board", + "description": "Renders the Board card once above the position list on the Positions page.", + "pages": [ + "positions" + ], + "status": "active", + "ui": { + "type": "card", + "placement": "after-position-list-summary", + "title": "Board", + "source": "candidates.activity", + "periods": [ + "today", + "last-7-days", + "previous-month" + ] + } + }, + "body": "# Board\n\n## Purpose\n\nThe page half of the Board, and only that half. One card, above the position\ngrid, reading activity across the workspace.\n\n`after-position-list-summary` renders once for the page and supplies no\nposition — `surfaces.js` records that placement as providing nothing — so the\ncard reads `candidates.activity`, which declares `context: null` and counts\napplications across the workspace over the periods named here.\n`position.activity` is refused at this placement by `unresolvableSections`, and\ncorrectly: it needs one position, and this placement has none to give.\n\nThis definition declares no `owliver:` block, so it contributes no capability,\nno suggestion chip and no conversational answer. It is a page extension and\nnothing else.\n\n## Capabilities\n\n- Show workspace application activity above the position list.\n- Compare today, the last 7 days and the previous month in one card." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "board-ui-test", + "name": "Board", + "description": "Renders the Board card once above the position list on the Positions page.", + "status": "active", + "pages": [ + "positions" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "board" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "ui" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "path": "skill-examples/owliver-conversation-test.md", + "type": "example", + "rawBase64": "LS0tCmlkOiBvd2xpdmVyLWNvbnZlcnNhdGlvbi10ZXN0Cm5hbWU6IFdvcmtzcGFjZSBBY3Rpdml0eSBBc3Npc3RhbnQKZGVzY3JpcHRpb246IEFuc3dlciBxdWVzdGlvbnMgYWJvdXQgYXBwbGljYXRpb25zIGFjcm9zcyB0aGUgd29ya3NwYWNlIGFuZCB0aGUgcm9sZXMgc3RpbGwgb3Blbi4KcGFnZXM6CiAgLSBwb3NpdGlvbnMKc3RhdHVzOiBhY3RpdmUKdHJpZ2dlcnM6CiAgLSB3b3Jrc3BhY2UgYXBwbGljYXRpb25zCiAgLSBhcHBsaWNhdGlvbnMgYWNyb3NzIHRoZSB3b3Jrc3BhY2UKICAtIHJvbGVzIHN0aWxsIG9wZW4KICAtIHJvbGVzIG5lZWRpbmcgYXR0ZW50aW9uCm93bGl2ZXI6CiAgZW5hYmxlZDogdHJ1ZQogICMgRXZlcnkgc3VnZ2VzdGlvbiBuYW1lcyB0aGUgY2FwYWJpbGl0eSBpdCBhc2tzIGZvciwgc28gYSBjaGlwIGFuZCBhbiBhbnN3ZXIKICAjIGFyZSBvbmUtdG8tb25lIHJhdGhlciB0aGFuIGJvdGggZmFsbGluZyB0aHJvdWdoIHRvIHRoZSBmaXJzdCBkZWNsYXJlZAogICMgY2FwYWJpbGl0eS4KICBzdWdnZXN0aW9uczoKICAgIC0gbGFiZWw6IFN1bW1hcml6ZSB3b3Jrc3BhY2UgYXBwbGljYXRpb25zCiAgICAgIGNhcGFiaWxpdHk6IHN1bW1hcnkKICAgIC0gbGFiZWw6IExpc3QgdGhlIHJvbGVzIHN0aWxsIG9wZW4KICAgICAgY2FwYWJpbGl0eTogbGlzdAogIGNhcGFiaWxpdGllczoKICAgIC0gc3VtbWFyeQogICAgLSBsaXN0CiAgcmVzcG9uc2VzOgogICAgc3VtbWFyeToKICAgICAgdGl0bGU6IFdvcmtzcGFjZSBBcHBsaWNhdGlvbnMKICAgICAgc291cmNlOiBjYW5kaWRhdGVzLmFjdGl2aXR5CiAgICAgIHBlcmlvZHM6CiAgICAgICAgLSB0b2RheQogICAgICAgIC0gbGFzdC03LWRheXMKICAgICAgICAtIHByZXZpb3VzLW1vbnRoCiAgICBsaXN0OgogICAgICB0aXRsZTogUm9sZXMgU3RpbGwgT3BlbgogICAgICBzb3VyY2U6IHBvc2l0aW9ucy5kZW1hbmQKICAgICAgbGltaXQ6IDUKLS0tCgojIFdvcmtzcGFjZSBBY3Rpdml0eSBBc3Npc3RhbnQKCiMjIFB1cnBvc2UKClRoZSBwYW5lbCBoYWxmLCBhbmQgb25seSB0aGF0IGhhbGYuIE5vIGB1aTpgIGJsb2NrLCBzbyBub3RoaW5nIGlzIGRyYXduIG9uIHRoZQpQb3NpdGlvbnMgcGFnZSBhbmQgbm8gY2FyZCBhcHBlYXJzIGFib3ZlIG9yIGluc2lkZSB0aGUgZ3JpZC4KCkVhY2ggcmVzcG9uc2UgbmFtZXMgaXRzIG93biBgc291cmNlOmAgZGlyZWN0bHksIHdoaWNoIGlzIHdoYXQgYSBkZWZpbml0aW9uCndpdGhvdXQgYSBgdWk6YCBzZWN0aW9uIG11c3QgZG8g4oCUIGBub3JtYWxpemVTa2lsbE93bGl2ZXJgIGluaGVyaXRzIGEgc291cmNlIGZyb20KdGhlIGZpcnN0IGB1aTpgIHNlY3Rpb24gb25seSB3aGVuIG9uZSBleGlzdHMsIGFuZCByZXBvcnRzCmBvd2xpdmVyLnJlc3BvbnNlcy48Y2FwYWJpbGl0eT46IGEgcmVzcG9uc2UgbmVlZHMgYSBzb3VyY2UsIG9yIGEgdWk6IHNlY3Rpb24gdG8KcmVhZCBmcm9tYCB3aGVuIG5laXRoZXIgaXMgcHJlc2VudC4KCkJvdGggc291cmNlcyBkZWNsYXJlIGBjb250ZXh0OiBudWxsYCwgc28gbmVpdGhlciBhbnN3ZXIgYXNrcyB3aGljaCBwb3NpdGlvbiBpcwptZWFudDogYHJlc29sdmVFbnRpdHlgIHJldHVybnMgaW1tZWRpYXRlbHkgYW5kIG5vCiJUaGlzIHNlY3Rpb24gbmVlZHMgYSBwb3NpdGlvbiB0byByZWFkIiBpcyBwb3NzaWJsZS4gVGhlIHNoYXBlcyBtYXRjaCB3aGF0IGVhY2gKc291cmNlIG9mZmVycyDigJQgYHN1bW1hcnlgIGlzIHByb3NlIGFuZCBpcyBleGVtcHQgZnJvbSB0aGUgc2hhcGUgY2hlY2ssIGFuZApgbGlzdGAgaXMgYSBzaGFwZSBgcG9zaXRpb25zLmRlbWFuZGAgZGVjbGFyZXMuCgojIyBDYXBhYmlsaXRpZXMKCi0gU3VtbWFyaXplIGFwcGxpY2F0aW9ucyBhY3Jvc3MgdGhlIHdvcmtzcGFjZSBvdmVyIHRvZGF5LCB0aGUgbGFzdCA3IGRheXMgYW5kCiAgdGhlIHByZXZpb3VzIG1vbnRoLgotIExpc3QgdGhlIG9wZW4gcG9zaXRpb25zIGFuZCBob3cgbWFueSBhcHBsaWNhbnRzIGVhY2ggaGFzLgo=", + "bytes": 2003, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "owliver-conversation-test", + "name": "Workspace Activity Assistant", + "description": "Answer questions about applications across the workspace and the roles still open.", + "pages": [ + "positions" + ], + "status": "active", + "triggers": [ + "workspace applications", + "applications across the workspace", + "roles still open", + "roles needing attention" + ], + "owliver": { + "enabled": true, + "suggestions": [ + { + "label": "Summarize workspace applications", + "capability": "summary" + }, + { + "label": "List the roles still open", + "capability": "list" + } + ], + "capabilities": [ + "summary", + "list" + ], + "responses": { + "summary": { + "title": "Workspace Applications", + "source": "candidates.activity", + "periods": [ + "today", + "last-7-days", + "previous-month" + ] + }, + "list": { + "title": "Roles Still Open", + "source": "positions.demand", + "limit": 5 + } + } + } + }, + "body": "# Workspace Activity Assistant\n\n## Purpose\n\nThe panel half, and only that half. No `ui:` block, so nothing is drawn on the\nPositions page and no card appears above or inside the grid.\n\nEach response names its own `source:` directly, which is what a definition\nwithout a `ui:` section must do — `normalizeSkillOwliver` inherits a source from\nthe first `ui:` section only when one exists, and reports\n`owliver.responses.: a response needs a source, or a ui: section to\nread from` when neither is present.\n\nBoth sources declare `context: null`, so neither answer asks which position is\nmeant: `resolveEntity` returns immediately and no\n\"This section needs a position to read\" is possible. The shapes match what each\nsource offers — `summary` is prose and is exempt from the shape check, and\n`list` is a shape `positions.demand` declares.\n\n## Capabilities\n\n- Summarize applications across the workspace over today, the last 7 days and\n the previous month.\n- List the open positions and how many applicants each has." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "owliver-conversation-test", + "name": "Workspace Activity Assistant", + "description": "Answer questions about applications across the workspace and the roles still open.", + "status": "active", + "pages": [ + "positions" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "workspace applications", + "applications across the workspace", + "roles still open", + "roles needing attention" + ], + "declaredTriggers": true, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + } + ], + "cases": [ + { + "name": "baseline-skill", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnN0YXR1czogYWN0aXZlCmFjdGlvbnM6CiAgLSBuYXZpZ2F0ZV90b19jYW5kaWRhdGVzCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCgojIyBDYXBhYmlsaXRpZXMKCi0gU3VtbWFyaXplIGNhbmRpZGF0ZXMuCg==", + "bytes": 238, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ], + "status": "active", + "actions": [ + "navigate_to_candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline.\n\n## Capabilities\n\n- Summarize candidates." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [ + "navigate_to_candidates" + ], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "baseline-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KaWNvbjogdXNlcnMKc3RhdHVzOiBwdWJsaXNoZWQKdmVyc2lvbjogMgpyZWFzb25pbmc6IGJhbGFuY2VkCnRyaWdnZXI6IFVzZSBvbiBjYW5kaWRhdGVzLgpwYWdlczoKICAtIGNhbmRpZGF0ZXMKc2tpbGxzOgogIC0gY2FuZGlkYXRlLXNlYXJjaApzdGFydGVyczoKICAtIGxhYmVsOiBXaG8gaXMgd2FpdGluZz8KICAgIHByb21wdDogV2hvIGlzIHdhaXRpbmcgb24gYSBkZWNpc2lvbj8KcGVybWlzc2lvbnM6CiAgb3duZXI6IGRlbW9Aa3Jvdy5hcHAKICBhY2Nlc3M6IGFsbAotLS0KCiMgU2FtcGxlIEFnZW50CgojIyBJbnN0cnVjdGlvbnMKCkFuc3dlciBhYm91dCBjYW5kaWRhdGVzLgoKIyMgUHVycG9zZQoKLSBSZXBvcnQgdGhlIHBpcGVsaW5lLgo=", + "bytes": 437, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "icon": "users", + "status": "published", + "version": 2, + "reasoning": "balanced", + "trigger": "Use on candidates.", + "pages": [ + "candidates" + ], + "skills": [ + "candidate-search" + ], + "starters": [ + { + "label": "Who is waiting?", + "prompt": "Who is waiting on a decision?" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all" + } + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer about candidates.\n\n## Purpose\n\n- Report the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "published", + "version": 2, + "pages": [ + "candidates" + ], + "icon": "users", + "reasoning": "balanced", + "trigger": "Use on candidates.", + "webSearch": false, + "skills": [ + "candidate-search" + ], + "subagents": [], + "starters": [ + { + "label": "Who is waiting?", + "prompt": "Who is waiting on a decision?" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "utf8-bom", + "rawBase64": "77u/LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnN0YXR1czogYWN0aXZlCmFjdGlvbnM6CiAgLSBuYXZpZ2F0ZV90b19jYW5kaWRhdGVzCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCgojIyBDYXBhYmlsaXRpZXMKCi0gU3VtbWFyaXplIGNhbmRpZGF0ZXMuCg==", + "bytes": 241, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ], + "status": "active", + "actions": [ + "navigate_to_candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline.\n\n## Capabilities\n\n- Summarize candidates." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [ + "navigate_to_candidates" + ], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "utf8-bom-agent", + "rawBase64": "77u/LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KaWNvbjogdXNlcnMKc3RhdHVzOiBwdWJsaXNoZWQKdmVyc2lvbjogMgpyZWFzb25pbmc6IGJhbGFuY2VkCnRyaWdnZXI6IFVzZSBvbiBjYW5kaWRhdGVzLgpwYWdlczoKICAtIGNhbmRpZGF0ZXMKc2tpbGxzOgogIC0gY2FuZGlkYXRlLXNlYXJjaApzdGFydGVyczoKICAtIGxhYmVsOiBXaG8gaXMgd2FpdGluZz8KICAgIHByb21wdDogV2hvIGlzIHdhaXRpbmcgb24gYSBkZWNpc2lvbj8KcGVybWlzc2lvbnM6CiAgb3duZXI6IGRlbW9Aa3Jvdy5hcHAKICBhY2Nlc3M6IGFsbAotLS0KCiMgU2FtcGxlIEFnZW50CgojIyBJbnN0cnVjdGlvbnMKCkFuc3dlciBhYm91dCBjYW5kaWRhdGVzLgoKIyMgUHVycG9zZQoKLSBSZXBvcnQgdGhlIHBpcGVsaW5lLgo=", + "bytes": 440, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "icon": "users", + "status": "published", + "version": 2, + "reasoning": "balanced", + "trigger": "Use on candidates.", + "pages": [ + "candidates" + ], + "skills": [ + "candidate-search" + ], + "starters": [ + { + "label": "Who is waiting?", + "prompt": "Who is waiting on a decision?" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all" + } + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer about candidates.\n\n## Purpose\n\n- Report the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "published", + "version": 2, + "pages": [ + "candidates" + ], + "icon": "users", + "reasoning": "balanced", + "trigger": "Use on candidates.", + "webSearch": false, + "skills": [ + "candidate-search" + ], + "subagents": [], + "starters": [ + { + "label": "Who is waiting?", + "prompt": "Who is waiting on a decision?" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "crlf", + "rawBase64": "LS0tDQppZDogc2FtcGxlLXNraWxsDQpuYW1lOiBTYW1wbGUgU2tpbGwNCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4NCnBhZ2VzOg0KICAtIGNhbmRpZGF0ZXMNCnN0YXR1czogYWN0aXZlDQphY3Rpb25zOg0KICAtIG5hdmlnYXRlX3RvX2NhbmRpZGF0ZXMNCi0tLQ0KDQojIFNhbXBsZSBTa2lsbA0KDQojIyBQdXJwb3NlDQoNCi0gUmVhZCB0aGUgcGlwZWxpbmUuDQoNCiMjIENhcGFiaWxpdGllcw0KDQotIFN1bW1hcml6ZSBjYW5kaWRhdGVzLg0K", + "bytes": 258, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ], + "status": "active", + "actions": [ + "navigate_to_candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline.\n\n## Capabilities\n\n- Summarize candidates." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [ + "navigate_to_candidates" + ], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "crlf-agent", + "rawBase64": "LS0tDQppZDogc2FtcGxlLWFnZW50DQpuYW1lOiBTYW1wbGUgQWdlbnQNCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4NCmljb246IHVzZXJzDQpzdGF0dXM6IHB1Ymxpc2hlZA0KdmVyc2lvbjogMg0KcmVhc29uaW5nOiBiYWxhbmNlZA0KdHJpZ2dlcjogVXNlIG9uIGNhbmRpZGF0ZXMuDQpwYWdlczoNCiAgLSBjYW5kaWRhdGVzDQpza2lsbHM6DQogIC0gY2FuZGlkYXRlLXNlYXJjaA0Kc3RhcnRlcnM6DQogIC0gbGFiZWw6IFdobyBpcyB3YWl0aW5nPw0KICAgIHByb21wdDogV2hvIGlzIHdhaXRpbmcgb24gYSBkZWNpc2lvbj8NCnBlcm1pc3Npb25zOg0KICBvd25lcjogZGVtb0Brcm93LmFwcA0KICBhY2Nlc3M6IGFsbA0KLS0tDQoNCiMgU2FtcGxlIEFnZW50DQoNCiMjIEluc3RydWN0aW9ucw0KDQpBbnN3ZXIgYWJvdXQgY2FuZGlkYXRlcy4NCg0KIyMgUHVycG9zZQ0KDQotIFJlcG9ydCB0aGUgcGlwZWxpbmUuDQo=", + "bytes": 467, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "icon": "users", + "status": "published", + "version": 2, + "reasoning": "balanced", + "trigger": "Use on candidates.", + "pages": [ + "candidates" + ], + "skills": [ + "candidate-search" + ], + "starters": [ + { + "label": "Who is waiting?", + "prompt": "Who is waiting on a decision?" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all" + } + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer about candidates.\n\n## Purpose\n\n- Report the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "published", + "version": 2, + "pages": [ + "candidates" + ], + "icon": "users", + "reasoning": "balanced", + "trigger": "Use on candidates.", + "webSearch": false, + "skills": [ + "candidate-search" + ], + "subagents": [], + "starters": [ + { + "label": "Who is waiting?", + "prompt": "Who is waiting on a decision?" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "cr-only", + "rawBase64": "LS0tDWlkOiBzYW1wbGUtc2tpbGwNbmFtZTogU2FtcGxlIFNraWxsDWRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4NcGFnZXM6DSAgLSBjYW5kaWRhdGVzDXN0YXR1czogYWN0aXZlDWFjdGlvbnM6DSAgLSBuYXZpZ2F0ZV90b19jYW5kaWRhdGVzDS0tLQ0NIyBTYW1wbGUgU2tpbGwNDSMjIFB1cnBvc2UNDS0gUmVhZCB0aGUgcGlwZWxpbmUuDQ0jIyBDYXBhYmlsaXRpZXMNDS0gU3VtbWFyaXplIGNhbmRpZGF0ZXMuDQ==", + "bytes": 238, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ], + "status": "active", + "actions": [ + "navigate_to_candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline.\n\n## Capabilities\n\n- Summarize candidates." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [ + "navigate_to_candidates" + ], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "bom-crlf-blankline", + "rawBase64": "77u/DQotLS0NCmlkOiBzYW1wbGUtc2tpbGwNCm5hbWU6IFNhbXBsZSBTa2lsbA0KZGVzY3JpcHRpb246IEEgc2FtcGxlIHNraWxsLg0KcGFnZXM6DQogIC0gY2FuZGlkYXRlcw0Kc3RhdHVzOiBhY3RpdmUNCmFjdGlvbnM6DQogIC0gbmF2aWdhdGVfdG9fY2FuZGlkYXRlcw0KLS0tDQoNCiMgU2FtcGxlIFNraWxsDQoNCiMjIFB1cnBvc2UNCg0KLSBSZWFkIHRoZSBwaXBlbGluZS4NCg0KIyMgQ2FwYWJpbGl0aWVzDQoNCi0gU3VtbWFyaXplIGNhbmRpZGF0ZXMuDQo=", + "bytes": 263, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ], + "status": "active", + "actions": [ + "navigate_to_candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline.\n\n## Capabilities\n\n- Summarize candidates." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [ + "navigate_to_candidates" + ], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "leading-blank-line", + "rawBase64": "Ci0tLQppZDogc2FtcGxlLXNraWxsCm5hbWU6IFNhbXBsZSBTa2lsbApkZXNjcmlwdGlvbjogQSBzYW1wbGUgc2tpbGwuCnBhZ2VzOgogIC0gY2FuZGlkYXRlcwpzdGF0dXM6IGFjdGl2ZQphY3Rpb25zOgogIC0gbmF2aWdhdGVfdG9fY2FuZGlkYXRlcwotLS0KCiMgU2FtcGxlIFNraWxsCgojIyBQdXJwb3NlCgotIFJlYWQgdGhlIHBpcGVsaW5lLgoKIyMgQ2FwYWJpbGl0aWVzCgotIFN1bW1hcml6ZSBjYW5kaWRhdGVzLgo=", + "bytes": 239, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ], + "status": "active", + "actions": [ + "navigate_to_candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline.\n\n## Capabilities\n\n- Summarize candidates." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [ + "navigate_to_candidates" + ], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "multiple-leading-blank-lines", + "rawBase64": "CgoKLS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnN0YXR1czogYWN0aXZlCmFjdGlvbnM6CiAgLSBuYXZpZ2F0ZV90b19jYW5kaWRhdGVzCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCgojIyBDYXBhYmlsaXRpZXMKCi0gU3VtbWFyaXplIGNhbmRpZGF0ZXMuCg==", + "bytes": 241, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ], + "status": "active", + "actions": [ + "navigate_to_candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline.\n\n## Capabilities\n\n- Summarize candidates." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [ + "navigate_to_candidates" + ], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "leading-spaces-then-blank-lines", + "rawBase64": "ICAgCiAKLS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnN0YXR1czogYWN0aXZlCmFjdGlvbnM6CiAgLSBuYXZpZ2F0ZV90b19jYW5kaWRhdGVzCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCgojIyBDYXBhYmlsaXRpZXMKCi0gU3VtbWFyaXplIGNhbmRpZGF0ZXMuCg==", + "bytes": 244, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ], + "status": "active", + "actions": [ + "navigate_to_candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline.\n\n## Capabilities\n\n- Summarize candidates." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [ + "navigate_to_candidates" + ], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "no-trailing-newline", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnN0YXR1czogYWN0aXZlCmFjdGlvbnM6CiAgLSBuYXZpZ2F0ZV90b19jYW5kaWRhdGVzCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCgojIyBDYXBhYmlsaXRpZXMKCi0gU3VtbWFyaXplIGNhbmRpZGF0ZXMu", + "bytes": 237, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ], + "status": "active", + "actions": [ + "navigate_to_candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline.\n\n## Capabilities\n\n- Summarize candidates." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [ + "navigate_to_candidates" + ], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "many-trailing-newlines", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnN0YXR1czogYWN0aXZlCmFjdGlvbnM6CiAgLSBuYXZpZ2F0ZV90b19jYW5kaWRhdGVzCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCgojIyBDYXBhYmlsaXRpZXMKCi0gU3VtbWFyaXplIGNhbmRpZGF0ZXMuCgoKCg==", + "bytes": 241, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ], + "status": "active", + "actions": [ + "navigate_to_candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline.\n\n## Capabilities\n\n- Summarize candidates." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [ + "navigate_to_candidates" + ], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "trailing-spaces-on-values", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwgICAKbmFtZTogU2FtcGxlIFNraWxsICAgCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4gICAKcGFnZXM6ICAgCiAgLSBjYW5kaWRhdGVzICAgCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCg==", + "bytes": 160, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "trailing-ws-after-open-fence", + "rawBase64": "LS0tICAgCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnN0YXR1czogYWN0aXZlCmFjdGlvbnM6CiAgLSBuYXZpZ2F0ZV90b19jYW5kaWRhdGVzCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCgojIyBDYXBhYmlsaXRpZXMKCi0gU3VtbWFyaXplIGNhbmRpZGF0ZXMuCg==", + "bytes": 241, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ], + "status": "active", + "actions": [ + "navigate_to_candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline.\n\n## Capabilities\n\n- Summarize candidates." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [ + "navigate_to_candidates" + ], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "trailing-tab-after-close-fence", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnN0YXR1czogYWN0aXZlCmFjdGlvbnM6CiAgLSBuYXZpZ2F0ZV90b19jYW5kaWRhdGVzCi0tLQkKCiMgU2FtcGxlIFNraWxsCgojIyBQdXJwb3NlCgotIFJlYWQgdGhlIHBpcGVsaW5lLgoKIyMgQ2FwYWJpbGl0aWVzCgotIFN1bW1hcml6ZSBjYW5kaWRhdGVzLgo=", + "bytes": 239, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ], + "status": "active", + "actions": [ + "navigate_to_candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline.\n\n## Capabilities\n\n- Summarize candidates." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [ + "navigate_to_candidates" + ], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "frontmatter-only-no-body", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCi0tLQ==", + "bytes": 94, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ] + }, + "body": "" + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "frontmatter-only-trailing-newline", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCi0tLQo=", + "bytes": 95, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ] + }, + "body": "" + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "double-quoted-scalar", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogIlNhbXBsZSBTa2lsbCIKZGVzY3JpcHRpb246ICJBIHNhbXBsZS4iCnBhZ2VzOgogIC0gY2FuZGlkYXRlcwotLS0KCiMgU2FtcGxlIFNraWxsCgojIyBQdXJwb3NlCgotIFJlYWQgdGhlIHBpcGVsaW5lLgo=", + "bytes": 143, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "single-quoted-scalar", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogJ1NhbXBsZSBTa2lsbCcKZGVzY3JpcHRpb246ICdBIHNhbXBsZS4nCnBhZ2VzOgogIC0gY2FuZGlkYXRlcwotLS0KCiMgU2FtcGxlIFNraWxsCgojIyBQdXJwb3NlCgotIFJlYWQgdGhlIHBpcGVsaW5lLgo=", + "bytes": 143, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "colon-in-quoted-string", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogIlNhbXBsZTogU2tpbGwiCmRlc2NyaXB0aW9uOiAiTm90ZTogcmVhZCB0aGlzLiIKcGFnZXM6CiAgLSBjYW5kaWRhdGVzCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCg==", + "bytes": 151, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample: Skill", + "description": "Note: read this.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample: Skill", + "description": "Note: read this.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample: skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "colon-in-unquoted-string", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBOb3RlOiByZWFkIHRoaXMuCnBhZ2VzOgogIC0gY2FuZGlkYXRlcwotLS0KCiMgU2FtcGxlIFNraWxsCgojIyBQdXJwb3NlCgotIFJlYWQgdGhlIHBpcGVsaW5lLgo=", + "bytes": 146, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "Note: read this.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "Note: read this.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "hash-in-quoted-string", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogIlNoaWZ0ICMxIgpkZXNjcmlwdGlvbjogIlRhZyAjb3BzIgpwYWdlczoKICAtIGNhbmRpZGF0ZXMKLS0tCgojIFNhbXBsZSBTa2lsbAoKIyMgUHVycG9zZQoKLSBSZWFkIHRoZSBwaXBlbGluZS4K", + "bytes": 138, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Shift #1", + "description": "Tag #ops", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Shift #1", + "description": "Tag #ops", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "shift #1" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "hash-unquoted-trailing-comment", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCmNhdGVnb3J5OiBvcHMgIyBhIHRyYWlsaW5nIGNvbW1lbnQKLS0tCgojIFNhbXBsZSBTa2lsbAoKIyMgUHVycG9zZQoKLSBSZWFkIHRoZSBwaXBlbGluZS4K", + "bytes": 180, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ], + "category": "ops" + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "ops", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "hash-unquoted-midword", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCmNhdGVnb3J5OiBvcHMjMQotLS0KCiMgU2FtcGxlIFNraWxsCgojIyBQdXJwb3NlCgotIFJlYWQgdGhlIHBpcGVsaW5lLgo=", + "bytes": 161, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ], + "category": "ops#1" + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "ops#1", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "doubled-quote-escape", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogIlNoZSBzYWlkICIiZ28iIiIKZGVzY3JpcHRpb246IEEgc2FtcGxlLgpwYWdlczoKICAtIGNhbmRpZGF0ZXMKLS0tCgojIFNhbXBsZSBTa2lsbAoKIyMgUHVycG9zZQoKLSBSZWFkIHRoZSBwaXBlbGluZS4K", + "bytes": 144, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "She said \"go\"", + "description": "A sample.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "She said \"go\"", + "description": "A sample.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "she said \"go\"" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "full-line-comment", + "rawBase64": "LS0tCiMgYSBjb21tZW50IGxpbmUKaWQ6IHNhbXBsZS1za2lsbApuYW1lOiBTYW1wbGUgU2tpbGwKZGVzY3JpcHRpb246IEEgc2FtcGxlIHNraWxsLgpwYWdlczoKICAtIGNhbmRpZGF0ZXMKLS0tCgojIFNhbXBsZSBTa2lsbAoKIyMgUHVycG9zZQoKLSBSZWFkIHRoZSBwaXBlbGluZS4K", + "bytes": 162, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "empty-scalar", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOgpwYWdlczoKICAtIGNhbmRpZGF0ZXMKLS0tCgojIFNhbXBsZSBTa2lsbAoKIyMgUHVycG9zZQoKLSBSZWFkIHRoZSBwaXBlbGluZS4K", + "bytes": 129, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": null, + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "tilde-scalar", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCmNhdGVnb3J5OiB+Ci0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCg==", + "bytes": 157, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ], + "category": null + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "null-scalar", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCmNhdGVnb3J5OiBudWxsCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCg==", + "bytes": 160, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ], + "category": null + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "boolean-scalar", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCndlYlNlYXJjaDogdHJ1ZQotLS0KCiMgU2FtcGxlIEFnZW50CgojIyBJbnN0cnVjdGlvbnMKCkFuc3dlci4K", + "bytes": 153, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "webSearch": true + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": true, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "integer-scalar", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnZlcnNpb246IDMKLS0tCgojIFNhbXBsZSBBZ2VudAoKIyMgSW5zdHJ1Y3Rpb25zCgpBbnN3ZXIuCg==", + "bytes": 148, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "version": 3 + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 3, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "float-scalar", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnZlcnNpb246IDEuNQotLS0KCiMgU2FtcGxlIEFnZW50CgojIyBJbnN0cnVjdGlvbnMKCkFuc3dlci4K", + "bytes": 150, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "version": 1.5 + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [ + "version: `1.5` is not a whole number of 1 or more." + ] + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "version: `1.5` is not a whole number of 1 or more." + }, + { + "name": "negative-integer", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnZlcnNpb246IC0yCi0tLQoKIyBTYW1wbGUgQWdlbnQKCiMjIEluc3RydWN0aW9ucwoKQW5zd2VyLgo=", + "bytes": 149, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "version": -2 + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [ + "version: `-2` is not a whole number of 1 or more." + ] + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "version: `-2` is not a whole number of 1 or more." + }, + { + "name": "empty-array", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZS4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCmFjdGlvbnM6Ci0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCg==", + "bytes": 148, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "pages": [ + "candidates" + ], + "actions": null + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "inline-flow-array", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZS4KcGFnZXM6IFtjYW5kaWRhdGVzXQotLS0KCiMgU2FtcGxlIFNraWxsCgojIyBQdXJwb3NlCgotIFJlYWQgdGhlIHBpcGVsaW5lLgo=", + "bytes": 137, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "pages": "[candidates]" + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "status": "active", + "pages": [], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "The frontmatter needs at least one `pages` entry." + }, + { + "name": "inline-flow-map", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnBlcm1pc3Npb25zOiB7b3duZXI6IGEsIGFjY2VzczogYWxsfQotLS0KCiMgU2FtcGxlIEFnZW50CgojIyBJbnN0cnVjdGlvbnMKCkFuc3dlci4K", + "bytes": 174, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "permissions": "{owner: a, access: all}" + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [ + "permissions: must be a mapping of `owner`, `access` and `people`." + ] + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "permissions: must be a mapping of `owner`, `access` and `people`." + }, + { + "name": "sequence-of-mappings", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KaWNvbjogdXNlcnMKc3RhdHVzOiBwdWJsaXNoZWQKdmVyc2lvbjogMgpyZWFzb25pbmc6IGJhbGFuY2VkCnRyaWdnZXI6IFVzZSBvbiBjYW5kaWRhdGVzLgpwYWdlczoKICAtIGNhbmRpZGF0ZXMKc2tpbGxzOgogIC0gY2FuZGlkYXRlLXNlYXJjaApzdGFydGVyczoKICAtIGxhYmVsOiBXaG8gaXMgd2FpdGluZz8KICAgIHByb21wdDogV2hvIGlzIHdhaXRpbmcgb24gYSBkZWNpc2lvbj8KcGVybWlzc2lvbnM6CiAgb3duZXI6IGRlbW9Aa3Jvdy5hcHAKICBhY2Nlc3M6IGFsbAotLS0KCiMgU2FtcGxlIEFnZW50CgojIyBJbnN0cnVjdGlvbnMKCkFuc3dlciBhYm91dCBjYW5kaWRhdGVzLgoKIyMgUHVycG9zZQoKLSBSZXBvcnQgdGhlIHBpcGVsaW5lLgo=", + "bytes": 437, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "icon": "users", + "status": "published", + "version": 2, + "reasoning": "balanced", + "trigger": "Use on candidates.", + "pages": [ + "candidates" + ], + "skills": [ + "candidate-search" + ], + "starters": [ + { + "label": "Who is waiting?", + "prompt": "Who is waiting on a decision?" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all" + } + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer about candidates.\n\n## Purpose\n\n- Report the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "published", + "version": 2, + "pages": [ + "candidates" + ], + "icon": "users", + "reasoning": "balanced", + "trigger": "Use on candidates.", + "webSearch": false, + "skills": [ + "candidate-search" + ], + "subagents": [], + "starters": [ + { + "label": "Who is waiting?", + "prompt": "Who is waiting on a decision?" + } + ], + "permissions": { + "owner": "demo@krow.app", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "nested-mapping", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnBlcm1pc3Npb25zOgogIG93bmVyOiBkZW1vQGtyb3cuYXBwCiAgYWNjZXNzOiBzcGVjaWZpYwogIHBlb3BsZToKICAgIC0gdXNlcjogYUBiLmMKICAgICAgcm9sZTogZWRpdG9yCi0tLQoKIyBTYW1wbGUgQWdlbnQKCiMjIEluc3RydWN0aW9ucwoKQW5zd2VyLgo=", + "bytes": 239, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "permissions": { + "owner": "demo@krow.app", + "access": "specific", + "people": [ + { + "user": "a@b.c", + "role": "editor" + } + ] + } + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "demo@krow.app", + "access": "specific", + "people": [ + { + "user": "a@b.c", + "role": "editor" + } + ] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "dash-alone-nested-block", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnN0YXJ0ZXJzOgogIC0KICAgIGxhYmVsOiBIZWxsbwogICAgcHJvbXB0OiBIZWxsbyB0aGVyZQotLS0KCiMgU2FtcGxlIEFnZW50CgojIyBJbnN0cnVjdGlvbnMKCkFuc3dlci4K", + "bytes": 192, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "starters": [ + { + "label": "Hello", + "prompt": "Hello there" + } + ] + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [ + { + "label": "Hello", + "prompt": "Hello there" + } + ], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "tab-indented-sequence", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZS4KcGFnZXM6CgktIGNhbmRpZGF0ZXMKLS0tCgojIFNhbXBsZSBTa2lsbAoKIyMgUHVycG9zZQoKLSBSZWFkIHRoZSBwaXBlbGluZS4K", + "bytes": 138, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "four-space-indent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZS4KcGFnZXM6CiAgICAtIGNhbmRpZGF0ZXMKLS0tCgojIFNhbXBsZSBTa2lsbAoKIyMgUHVycG9zZQoKLSBSZWFkIHRoZSBwaXBlbGluZS4K", + "bytes": 141, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "ragged-indent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZS4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCiAgICAgLSBwb3NpdGlvbnMKLS0tCgojIFNhbXBsZSBTa2lsbAoKIyMgUHVycG9zZQoKLSBSZWFkIHRoZSBwaXBlbGluZS4K", + "bytes": 156, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": false, + "error": "Unexpected indentation on line 6" + }, + "parse": { + "ok": false, + "error": "Unexpected indentation on line 6" + }, + "normalized": null, + "markdownVerbatim": null, + "accepted": false, + "rejection": "That definition could not be parsed. Unexpected indentation on line 6" + }, + { + "name": "block-scalar-literal", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiB8CiAgTGluZSBvbmUuCiAgTGluZSB0d28uCnBhZ2VzOgogIC0gY2FuZGlkYXRlcwotLS0KCiMgU2FtcGxlIFNraWxsCgojIyBQdXJwb3NlCgotIFJlYWQgdGhlIHBpcGVsaW5lLgo=", + "bytes": 155, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": false, + "error": "Unexpected indentation on line 4" + }, + "parse": { + "ok": false, + "error": "Unexpected indentation on line 4" + }, + "normalized": null, + "markdownVerbatim": null, + "accepted": false, + "rejection": "That definition could not be parsed. Unexpected indentation on line 4" + }, + { + "name": "block-scalar-folded", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiA+CiAgTGluZSBvbmUuCnBhZ2VzOgogIC0gY2FuZGlkYXRlcwotLS0KCiMgU2FtcGxlIFNraWxsCgojIyBQdXJwb3NlCgotIFJlYWQgdGhlIHBpcGVsaW5lLgo=", + "bytes": 143, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": false, + "error": "Unexpected indentation on line 4" + }, + "parse": { + "ok": false, + "error": "Unexpected indentation on line 4" + }, + "normalized": null, + "markdownVerbatim": null, + "accepted": false, + "rejection": "That definition could not be parsed. Unexpected indentation on line 4" + }, + { + "name": "anchor-and-alias", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogJm4gU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiAqbgpwYWdlczoKICAtIGNhbmRpZGF0ZXMKLS0tCgojIFNhbXBsZSBTa2lsbAoKIyMgUHVycG9zZQoKLSBSZWFkIHRoZSBwaXBlbGluZS4K", + "bytes": 135, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "&n Sample Skill", + "description": "*n", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "&n Sample Skill", + "description": "*n", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "&n sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "multi-document", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCi0tLQppZDogc2Vjb25kCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCg==", + "bytes": 160, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ] + }, + "body": "id: second\n---\n\n# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "duplicate-key", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogRmlyc3QgTmFtZQpuYW1lOiBTZWNvbmQgTmFtZQpkZXNjcmlwdGlvbjogQSBzYW1wbGUuCnBhZ2VzOgogIC0gY2FuZGlkYXRlcwotLS0KCiMgU2FtcGxlIFNraWxsCgojIyBQdXJwb3NlCgotIFJlYWQgdGhlIHBpcGVsaW5lLgo=", + "bytes": 155, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Second Name", + "description": "A sample.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Second Name", + "description": "A sample.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "second name" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "duplicate-key-array", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZS4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnBhZ2VzOgogIC0gcG9zaXRpb25zCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCg==", + "bytes": 160, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "pages": [ + "positions" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "status": "active", + "pages": [ + "positions" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "unsupported-frontmatter-field", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnVua25vd25GaWVsZDogd2hhdGV2ZXIKLS0tCgojIFNhbXBsZSBTa2lsbAoKIyMgUHVycG9zZQoKLSBSZWFkIHRoZSBwaXBlbGluZS4K", + "bytes": 168, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ], + "unknownField": "whatever" + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "unsupported-field-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCm5vbnNlbnNlOiAxCi0tLQoKIyBTYW1wbGUgQWdlbnQKCiMjIEluc3RydWN0aW9ucwoKQW5zd2VyLgo=", + "bytes": 149, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "nonsense": 1 + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "key-with-space", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbXkga2V5OiB2YWx1ZQpuYW1lOiBTYW1wbGUgU2tpbGwKZGVzY3JpcHRpb246IEEgc2FtcGxlLgpwYWdlczoKICAtIGNhbmRpZGF0ZXMKLS0tCgojIFNhbXBsZSBTa2lsbAoKIyMgUHVycG9zZQoKLSBSZWFkIHRoZSBwaXBlbGluZS4K", + "bytes": 153, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": false, + "error": "Line 2 is not `key: value`: my key: value" + }, + "parse": { + "ok": false, + "error": "Line 2 is not `key: value`: my key: value" + }, + "normalized": null, + "markdownVerbatim": null, + "accepted": false, + "rejection": "That definition could not be parsed. Line 2 is not `key: value`: my key: value" + }, + { + "name": "uppercase-key", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCkNhdGVnb3J5OiBPcHMKLS0tCgojIFNhbXBsZSBTa2lsbAoKIyMgUHVycG9zZQoKLSBSZWFkIHRoZSBwaXBlbGluZS4K", + "bytes": 159, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ], + "Category": "Ops" + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "malformed-yaml-bare-line", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKdGhpcyBpcyBub3QgYSBwYWlyCm5hbWU6IFNhbXBsZSBTa2lsbApwYWdlczoKICAtIGNhbmRpZGF0ZXMKLS0tCgojIFNhbXBsZSBTa2lsbAoKIyMgUHVycG9zZQoKLSBSZWFkIHRoZSBwaXBlbGluZS4K", + "bytes": 135, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": false, + "error": "Line 2 is not `key: value`: this is not a pair" + }, + "parse": { + "ok": false, + "error": "Line 2 is not `key: value`: this is not a pair" + }, + "normalized": null, + "markdownVerbatim": null, + "accepted": false, + "rejection": "That definition could not be parsed. Line 2 is not `key: value`: this is not a pair" + }, + { + "name": "malformed-open-fence-two-dashes", + "rawBase64": "LS0KaWQ6IHNhbXBsZS1za2lsbApuYW1lOiBTYW1wbGUgU2tpbGwKZGVzY3JpcHRpb246IEEgc2FtcGxlIHNraWxsLgpwYWdlczoKICAtIGNhbmRpZGF0ZXMKc3RhdHVzOiBhY3RpdmUKYWN0aW9uczoKICAtIG5hdmlnYXRlX3RvX2NhbmRpZGF0ZXMKLS0tCgojIFNhbXBsZSBTa2lsbAoKIyMgUHVycG9zZQoKLSBSZWFkIHRoZSBwaXBlbGluZS4KCiMjIENhcGFiaWxpdGllcwoKLSBTdW1tYXJpemUgY2FuZGlkYXRlcy4K", + "bytes": 237, + "kind": "skill", + "hasFrontmatter": false, + "frontmatter": { + "ok": true, + "data": {}, + "body": "--\nid: sample-skill\nname: Sample Skill\ndescription: A sample skill.\npages:\n - candidates\nstatus: active\nactions:\n - navigate_to_candidates\n---\n\n# Sample Skill\n\n## Purpose\n\n- Read the pipeline.\n\n## Capabilities\n\n- Summarize candidates.\n" + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "custom", + "name": "Untitled skill", + "description": "", + "status": "active", + "pages": [], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "The frontmatter needs at least one `pages` entry." + }, + { + "name": "malformed-open-fence-four-dashes", + "rawBase64": "LS0tLQppZDogc2FtcGxlLXNraWxsCm5hbWU6IFNhbXBsZSBTa2lsbApkZXNjcmlwdGlvbjogQSBzYW1wbGUgc2tpbGwuCnBhZ2VzOgogIC0gY2FuZGlkYXRlcwpzdGF0dXM6IGFjdGl2ZQphY3Rpb25zOgogIC0gbmF2aWdhdGVfdG9fY2FuZGlkYXRlcwotLS0KCiMgU2FtcGxlIFNraWxsCgojIyBQdXJwb3NlCgotIFJlYWQgdGhlIHBpcGVsaW5lLgoKIyMgQ2FwYWJpbGl0aWVzCgotIFN1bW1hcml6ZSBjYW5kaWRhdGVzLgo=", + "bytes": 239, + "kind": "skill", + "hasFrontmatter": false, + "frontmatter": { + "ok": true, + "data": {}, + "body": "----\nid: sample-skill\nname: Sample Skill\ndescription: A sample skill.\npages:\n - candidates\nstatus: active\nactions:\n - navigate_to_candidates\n---\n\n# Sample Skill\n\n## Purpose\n\n- Read the pipeline.\n\n## Capabilities\n\n- Summarize candidates.\n" + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "custom", + "name": "Untitled skill", + "description": "", + "status": "active", + "pages": [], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "The frontmatter needs at least one `pages` entry." + }, + { + "name": "malformed-open-fence-indented", + "rawBase64": "IC0tLQppZDogc2FtcGxlLXNraWxsCm5hbWU6IFNhbXBsZSBTa2lsbApkZXNjcmlwdGlvbjogQSBzYW1wbGUgc2tpbGwuCnBhZ2VzOgogIC0gY2FuZGlkYXRlcwpzdGF0dXM6IGFjdGl2ZQphY3Rpb25zOgogIC0gbmF2aWdhdGVfdG9fY2FuZGlkYXRlcwotLS0KCiMgU2FtcGxlIFNraWxsCgojIyBQdXJwb3NlCgotIFJlYWQgdGhlIHBpcGVsaW5lLgoKIyMgQ2FwYWJpbGl0aWVzCgotIFN1bW1hcml6ZSBjYW5kaWRhdGVzLgo=", + "bytes": 239, + "kind": "skill", + "hasFrontmatter": false, + "frontmatter": { + "ok": true, + "data": {}, + "body": " ---\nid: sample-skill\nname: Sample Skill\ndescription: A sample skill.\npages:\n - candidates\nstatus: active\nactions:\n - navigate_to_candidates\n---\n\n# Sample Skill\n\n## Purpose\n\n- Read the pipeline.\n\n## Capabilities\n\n- Summarize candidates.\n" + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "custom", + "name": "Untitled skill", + "description": "", + "status": "active", + "pages": [], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "The frontmatter needs at least one `pages` entry." + }, + { + "name": "malformed-open-fence-text-after", + "rawBase64": "LS0teWFtbAppZDogc2FtcGxlLXNraWxsCm5hbWU6IFNhbXBsZSBTa2lsbApkZXNjcmlwdGlvbjogQSBzYW1wbGUgc2tpbGwuCnBhZ2VzOgogIC0gY2FuZGlkYXRlcwpzdGF0dXM6IGFjdGl2ZQphY3Rpb25zOgogIC0gbmF2aWdhdGVfdG9fY2FuZGlkYXRlcwotLS0KCiMgU2FtcGxlIFNraWxsCgojIyBQdXJwb3NlCgotIFJlYWQgdGhlIHBpcGVsaW5lLgoKIyMgQ2FwYWJpbGl0aWVzCgotIFN1bW1hcml6ZSBjYW5kaWRhdGVzLgo=", + "bytes": 242, + "kind": "skill", + "hasFrontmatter": false, + "frontmatter": { + "ok": true, + "data": {}, + "body": "---yaml\nid: sample-skill\nname: Sample Skill\ndescription: A sample skill.\npages:\n - candidates\nstatus: active\nactions:\n - navigate_to_candidates\n---\n\n# Sample Skill\n\n## Purpose\n\n- Read the pipeline.\n\n## Capabilities\n\n- Summarize candidates.\n" + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "custom", + "name": "Untitled skill", + "description": "", + "status": "active", + "pages": [], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "The frontmatter needs at least one `pages` entry." + }, + { + "name": "malformed-close-fence-two-dashes", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCi0tCgojIEJvZHkK", + "bytes": 102, + "kind": "skill", + "hasFrontmatter": false, + "frontmatter": { + "ok": true, + "data": {}, + "body": "---\nid: sample-skill\nname: Sample Skill\ndescription: A sample skill.\npages:\n - candidates\n--\n\n# Body\n" + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "custom", + "name": "Untitled skill", + "description": "", + "status": "active", + "pages": [], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "The frontmatter needs at least one `pages` entry." + }, + { + "name": "malformed-close-fence-missing", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCgojIEJvZHkK", + "bytes": 99, + "kind": "skill", + "hasFrontmatter": false, + "frontmatter": { + "ok": true, + "data": {}, + "body": "---\nid: sample-skill\nname: Sample Skill\ndescription: A sample skill.\npages:\n - candidates\n\n# Body\n" + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "custom", + "name": "Untitled skill", + "description": "", + "status": "active", + "pages": [], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "The frontmatter needs at least one `pages` entry." + }, + { + "name": "malformed-close-fence-four-dashes", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCi0tLS0KCiMgQm9keQo=", + "bytes": 104, + "kind": "skill", + "hasFrontmatter": false, + "frontmatter": { + "ok": true, + "data": {}, + "body": "---\nid: sample-skill\nname: Sample Skill\ndescription: A sample skill.\npages:\n - candidates\n----\n\n# Body\n" + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "custom", + "name": "Untitled skill", + "description": "", + "status": "active", + "pages": [], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "The frontmatter needs at least one `pages` entry." + }, + { + "name": "empty-fence-pair", + "rawBase64": "LS0tCi0tLQoKIyBCb2R5Cg==", + "bytes": 16, + "kind": "skill", + "hasFrontmatter": false, + "frontmatter": { + "ok": true, + "data": {}, + "body": "---\n---\n\n# Body\n" + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "custom", + "name": "Untitled skill", + "description": "", + "status": "active", + "pages": [], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "The frontmatter needs at least one `pages` entry." + }, + { + "name": "empty-fence-with-blank", + "rawBase64": "LS0tCgotLS0KCiMgQm9keQo=", + "bytes": 17, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": {}, + "body": "# Body" + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "custom", + "name": "Untitled skill", + "description": "", + "status": "active", + "pages": [], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "The frontmatter needs at least one `pages` entry." + }, + { + "name": "missing-frontmatter", + "rawBase64": "IyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCg==", + "bytes": 49, + "kind": "skill", + "hasFrontmatter": false, + "frontmatter": { + "ok": true, + "data": {}, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline.\n" + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "custom", + "name": "Untitled skill", + "description": "", + "status": "active", + "pages": [], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "The frontmatter needs at least one `pages` entry." + }, + { + "name": "empty-markdown", + "rawBase64": "", + "bytes": 0, + "kind": "skill", + "hasFrontmatter": false, + "frontmatter": { + "ok": true, + "data": {}, + "body": "" + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "custom", + "name": "Untitled skill", + "description": "", + "status": "active", + "pages": [], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "Paste or upload a Markdown definition." + }, + { + "name": "whitespace-only-markdown", + "rawBase64": "ICAgCgoJCg==", + "bytes": 7, + "kind": "skill", + "hasFrontmatter": false, + "frontmatter": { + "ok": true, + "data": {}, + "body": "" + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "custom", + "name": "Untitled skill", + "description": "", + "status": "active", + "pages": [], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "Paste or upload a Markdown definition." + }, + { + "name": "frontmatter-is-a-sequence", + "rawBase64": "LS0tCi0gb25lCi0gdHdvCi0tLQoKIyBCb2R5Cg==", + "bytes": 28, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": {}, + "body": "# Body" + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "custom", + "name": "Untitled skill", + "description": "", + "status": "active", + "pages": [], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "The frontmatter needs at least one `pages` entry." + }, + { + "name": "invalid-definition-id-uppercase", + "rawBase64": "LS0tCmlkOiBTYW1wbGVfU2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZS4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCg==", + "bytes": 139, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "Sample_Skill", + "name": "Sample Skill", + "description": "A sample.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "Sample_Skill", + "name": "Sample Skill", + "description": "A sample.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "`id` must be lower-case letters, numbers and dashes." + }, + { + "name": "invalid-definition-id-leading-dash", + "rawBase64": "LS0tCmlkOiAtc2FtcGxlCm5hbWU6IFNhbXBsZSBTa2lsbApkZXNjcmlwdGlvbjogQSBzYW1wbGUuCnBhZ2VzOgogIC0gY2FuZGlkYXRlcwotLS0KCiMgU2FtcGxlIFNraWxsCgojIyBQdXJwb3NlCgotIFJlYWQgdGhlIHBpcGVsaW5lLgo=", + "bytes": 134, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "-sample", + "name": "Sample Skill", + "description": "A sample.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "-sample", + "name": "Sample Skill", + "description": "A sample.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "`id` must be lower-case letters, numbers and dashes." + }, + { + "name": "invalid-definition-id-underscore", + "rawBase64": "LS0tCmlkOiBzYW1wbGVfc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZS4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCg==", + "bytes": 139, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample_skill", + "name": "Sample Skill", + "description": "A sample.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample_skill", + "name": "Sample Skill", + "description": "A sample.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "`id` must be lower-case letters, numbers and dashes." + }, + { + "name": "missing-id-derives-from-name", + "rawBase64": "LS0tCm5hbWU6IFNhbXBsZSBTa2lsbApkZXNjcmlwdGlvbjogQSBzYW1wbGUuCnBhZ2VzOgogIC0gY2FuZGlkYXRlcwotLS0KCiMgU2FtcGxlIFNraWxsCgojIyBQdXJwb3NlCgotIFJlYWQgdGhlIHBpcGVsaW5lLgo=", + "bytes": 122, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "name": "Sample Skill", + "description": "A sample.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "missing-name", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKZGVzY3JpcHRpb246IEEgc2FtcGxlLgpwYWdlczoKICAtIGNhbmRpZGF0ZXMKLS0tCgojIFNhbXBsZSBTa2lsbAoKIyMgUHVycG9zZQoKLSBSZWFkIHRoZSBwaXBlbGluZS4K", + "bytes": 120, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "description": "A sample.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Untitled skill", + "description": "A sample.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "missing-pages", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZS4KLS0tCgojIFNhbXBsZSBTa2lsbAoKIyMgUHVycG9zZQoKLSBSZWFkIHRoZSBwaXBlbGluZS4K", + "bytes": 117, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample." + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "status": "active", + "pages": [], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "The frontmatter needs at least one `pages` entry." + }, + { + "name": "unknown-page", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZS4KcGFnZXM6CiAgLSBub3doZXJlCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCg==", + "bytes": 136, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "pages": [ + "nowhere" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "status": "active", + "pages": [ + "nowhere" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "Unsupported page: nowhere. Supported pages: control-center, positions, create-position, candidates, hired-history, talent-pool, krow-forge, analytics, activity, workspace-agent-configure, settings, workspace, workspace-agents, workspace-skills, workspace-skill-configure, skill-development, profile, candidates-analysis." + }, + { + "name": "page-alias-uppercase", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZS4KcGFnZXM6CiAgLSBDYW5kaWRhdGVzCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCg==", + "bytes": 139, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "pages": [ + "Candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "status": "active", + "pages": [ + "Candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "page-alias-underscore", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZS4KcGFnZXM6CiAgLSB0YWxlbnRfcG9vbAotLS0KCiMgU2FtcGxlIFNraWxsCgojIyBQdXJwb3NlCgotIFJlYWQgdGhlIHBpcGVsaW5lLgo=", + "bytes": 140, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "pages": [ + "talent_pool" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "status": "active", + "pages": [ + "talent_pool" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "invalid-status-skill", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnN0YXR1czogYm9ndXMKLS0tCgojIFNhbXBsZSBTa2lsbAoKIyMgUHVycG9zZQoKLSBSZWFkIHRoZSBwaXBlbGluZS4K", + "bytes": 159, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ], + "status": "bogus" + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "inactive-status-skill", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnN0YXR1czogaW5hY3RpdmUKLS0tCgojIFNhbXBsZSBTa2lsbAoKIyMgUHVycG9zZQoKLSBSZWFkIHRoZSBwaXBlbGluZS4K", + "bytes": 162, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ], + "status": "inactive" + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "inactive", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "invalid-status-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnN0YXR1czogYm9ndXMKLS0tCgojIFNhbXBsZSBBZ2VudAoKIyMgSW5zdHJ1Y3Rpb25zCgpBbnN3ZXIuCg==", + "bytes": 151, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "status": "bogus" + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [ + "status: `bogus` is not a status. Use one of draft, published, archived." + ] + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "status: `bogus` is not a status. Use one of draft, published, archived." + }, + { + "name": "invalid-reasoning-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnJlYXNvbmluZzogdHVyYm8KLS0tCgojIFNhbXBsZSBBZ2VudAoKIyMgSW5zdHJ1Y3Rpb25zCgpBbnN3ZXIuCg==", + "bytes": 154, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "reasoning": "turbo" + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [ + "reasoning: `turbo` is not a reasoning mode. Use one of fast, balanced, deep." + ] + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "reasoning: `turbo` is not a reasoning mode. Use one of fast, balanced, deep." + }, + { + "name": "invalid-icon-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCmljb246IHJvY2tldAotLS0KCiMgU2FtcGxlIEFnZW50CgojIyBJbnN0cnVjdGlvbnMKCkFuc3dlci4K", + "bytes": 150, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "icon": "rocket" + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [ + "icon: `rocket` is not an icon this product has." + ] + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "icon: `rocket` is not an icon this product has." + }, + { + "name": "invalid-version-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnZlcnNpb246IHplcm8KLS0tCgojIFNhbXBsZSBBZ2VudAoKIyMgSW5zdHJ1Y3Rpb25zCgpBbnN3ZXIuCg==", + "bytes": 151, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "version": "zero" + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [ + "version: `zero` is not a whole number of 1 or more." + ] + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "version: `zero` is not a whole number of 1 or more." + }, + { + "name": "invalid-field-type-pages-scalar", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZS4KcGFnZXM6IGNhbmRpZGF0ZXMKLS0tCgojIFNhbXBsZSBTa2lsbAoKIyMgUHVycG9zZQoKLSBSZWFkIHRoZSBwaXBlbGluZS4K", + "bytes": 135, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "pages": "candidates" + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "status": "active", + "pages": [], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "The frontmatter needs at least one `pages` entry." + }, + { + "name": "invalid-field-type-pages-scalar-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZS4KcGFnZXM6IGNhbmRpZGF0ZXMKLS0tCgojIFNhbXBsZSBBZ2VudAoKIyMgSW5zdHJ1Y3Rpb25zCgpBbnN3ZXIuCg==", + "bytes": 127, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample.", + "pages": "candidates" + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "invalid-field-type-name-list", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZToKICAtIGEKICAtIGIKZGVzY3JpcHRpb246IEEgc2FtcGxlLgpwYWdlczoKICAtIGNhbmRpZGF0ZXMKLS0tCgojIFNhbXBsZSBTa2lsbAoKIyMgUHVycG9zZQoKLSBSZWFkIHRoZSBwaXBlbGluZS4K", + "bytes": 138, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": [ + "a", + "b" + ], + "description": "A sample.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": [ + "a", + "b" + ], + "description": "A sample.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "a,b" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "invalid-permissions-access", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnBlcm1pc3Npb25zOgogIGFjY2Vzczogbm9ib2R5Ci0tLQoKIyBTYW1wbGUgQWdlbnQKCiMjIEluc3RydWN0aW9ucwoKQW5zd2VyLgo=", + "bytes": 167, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "permissions": { + "access": "nobody" + } + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [ + "permissions.access: `nobody` is not an access mode. Use one of all, specific." + ] + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "permissions.access: `nobody` is not an access mode. Use one of all, specific." + }, + { + "name": "self-subagent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnN1YmFnZW50czoKICAtIHNhbXBsZS1hZ2VudAotLS0KCiMgU2FtcGxlIEFnZW50CgojIyBJbnN0cnVjdGlvbnMKCkFuc3dlci4K", + "bytes": 165, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "subagents": [ + "sample-agent" + ] + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [ + "subagents: an agent cannot be its own subagent." + ] + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "subagents: an agent cannot be its own subagent." + }, + { + "name": "oversized-markdown", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCgp4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eA==", + "bytes": 65746, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline.\n\nxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "at-size-bound", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCnl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eQ==", + "bytes": 65536, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline.\nyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "missing-name-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKZGVzY3JpcHRpb246IEEgc2FtcGxlLgpwYWdlczoKICAtIGNhbmRpZGF0ZXMKLS0tCgojIFNhbXBsZSBBZ2VudAoKIyMgSW5zdHJ1Y3Rpb25zCgpBbnN3ZXIuCg==", + "bytes": 112, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "description": "A sample.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Untitled agent", + "description": "A sample.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "The frontmatter needs a `name`." + }, + { + "name": "missing-pages-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZS4KLS0tCgojIFNhbXBsZSBBZ2VudAoKIyMgSW5zdHJ1Y3Rpb25zCgpBbnN3ZXIuCg==", + "bytes": 109, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample." + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample.", + "status": "draft", + "version": 1, + "pages": [], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "An agent needs at least one `pages:` entry, or it can never be offered anywhere." + }, + { + "name": "unknown-page-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZS4KcGFnZXM6CiAgLSBub3doZXJlCi0tLQoKIyBTYW1wbGUgQWdlbnQKCiMjIEluc3RydWN0aW9ucwoKQW5zd2VyLgo=", + "bytes": 128, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample.", + "pages": [ + "nowhere" + ] + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample.", + "status": "draft", + "version": 1, + "pages": [], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [ + "pages[0]: `nowhere` is not a page this product has." + ] + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "An agent needs at least one `pages:` entry, or it can never be offered anywhere." + }, + { + "name": "oversized-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCi0tLQoKIyBTYW1wbGUgQWdlbnQKCiMjIEluc3RydWN0aW9ucwoKQW5zd2VyLgoKeHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHg=", + "bytes": 65738, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer.\n\nxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "visibility-field-personal", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnZpc2liaWxpdHk6IHBlcnNvbmFsCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCg==", + "bytes": 166, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ], + "visibility": "personal" + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "visibility-field-invalid", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnZpc2liaWxpdHk6IG5vYm9keQotLS0KCiMgU2FtcGxlIFNraWxsCgojIyBQdXJwb3NlCgotIFJlYWQgdGhlIHBpcGVsaW5lLgo=", + "bytes": 164, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ], + "visibility": "nobody" + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "blank-page-entry-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZS4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCiAgLSAiIgotLS0KCiMgU2FtcGxlIEFnZW50CgojIyBJbnN0cnVjdGlvbnMKCkFuc3dlci4K", + "bytes": 138, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample.", + "pages": [ + "candidates", + "" + ] + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [ + "pages[1]: a page cannot be blank." + ] + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "pages[1]: a page cannot be blank." + }, + { + "name": "duplicate-page-entry-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZS4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCiAgLSBjYW5kaWRhdGVzCi0tLQoKIyBTYW1wbGUgQWdlbnQKCiMjIEluc3RydWN0aW9ucwoKQW5zd2VyLgo=", + "bytes": 146, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample.", + "pages": [ + "candidates", + "candidates" + ] + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "id-with-trailing-space", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwgICAKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZS4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCg==", + "bytes": 142, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "id-quoted", + "rawBase64": "LS0tCmlkOiAic2FtcGxlLXNraWxsIgpuYW1lOiBTYW1wbGUgU2tpbGwKZGVzY3JpcHRpb246IEEgc2FtcGxlLgpwYWdlczoKICAtIGNhbmRpZGF0ZXMKLS0tCgojIFNhbXBsZSBTa2lsbAoKIyMgUHVycG9zZQoKLSBSZWFkIHRoZSBwaXBlbGluZS4K", + "bytes": 141, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "starters-plain-strings-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnN0YXJ0ZXJzOgogIC0gV2hvIGlzIHdhaXRpbmc/CiAgLSBXaGVyZSBhcmUgdGhlIGdhcHM/Ci0tLQoKIyBTYW1wbGUgQWdlbnQKCiMjIEluc3RydWN0aW9ucwoKQW5zd2VyLgo=", + "bytes": 191, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "starters": [ + "Who is waiting?", + "Where are the gaps?" + ] + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [ + { + "label": "Who is waiting?", + "prompt": "Who is waiting?" + }, + { + "label": "Where are the gaps?", + "prompt": "Where are the gaps?" + } + ], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "starter-missing-label-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnN0YXJ0ZXJzOgogIC0gcHJvbXB0OiBPbmx5IGEgcHJvbXB0Ci0tLQoKIyBTYW1wbGUgQWdlbnQKCiMjIEluc3RydWN0aW9ucwoKQW5zd2VyLgo=", + "bytes": 173, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "starters": [ + { + "prompt": "Only a prompt" + } + ] + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [ + { + "label": "Only a prompt", + "prompt": "Only a prompt" + } + ], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "knowledge-note-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCmtub3dsZWRnZToKICAtIGxhYmVsOiBBIG5vdGUKICAgIGJvZHk6IFRoZSBib2R5IG9mIHRoZSBub3RlLgotLS0KCiMgU2FtcGxlIEFnZW50CgojIyBJbnN0cnVjdGlvbnMKCkFuc3dlci4K", + "bytes": 198, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "knowledge": [ + { + "label": "A note", + "body": "The body of the note." + } + ] + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "knowledge-bad-kind-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCmtub3dsZWRnZToKICAtIGxhYmVsOiBBIG5vdGUKICAgIGtpbmQ6IHJ1bW91cgogICAgYm9keTogeAotLS0KCiMgU2FtcGxlIEFnZW50CgojIyBJbnN0cnVjdGlvbnMKCkFuc3dlci4K", + "bytes": 195, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "knowledge": [ + { + "label": "A note", + "kind": "rumour", + "body": "x" + } + ] + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [ + "knowledge[0]: `rumour` is not a knowledge kind. Use one of note, link, skill-reference." + ] + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "knowledge[0]: `rumour` is not a knowledge kind. Use one of note, link, skill-reference." + }, + { + "name": "knowledge-link-without-url-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCmtub3dsZWRnZToKICAtIGxhYmVsOiBBIGxpbmsKICAgIGtpbmQ6IGxpbmsKLS0tCgojIFNhbXBsZSBBZ2VudAoKIyMgSW5zdHJ1Y3Rpb25zCgpBbnN3ZXIuCg==", + "bytes": 181, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "knowledge": [ + { + "label": "A link", + "kind": "link" + } + ] + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [ + "knowledge[0]: a `link` needs a `url`." + ] + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "knowledge[0]: a `link` needs a `url`." + }, + { + "name": "skills-scalar-coerced-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnNraWxsczogY2FuZGlkYXRlLXNlYXJjaAotLS0KCiMgU2FtcGxlIEFnZW50CgojIyBJbnN0cnVjdGlvbnMKCkFuc3dlci4K", + "bytes": 162, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "skills": "candidate-search" + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [ + "candidate-search" + ], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "skills-blank-entry-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnNraWxsczoKICAtIGNhbmRpZGF0ZS1zZWFyY2gKICAtICIiCi0tLQoKIyBTYW1wbGUgQWdlbnQKCiMjIEluc3RydWN0aW9ucwoKQW5zd2VyLgo=", + "bytes": 173, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "skills": [ + "candidate-search", + "" + ] + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [ + "candidate-search" + ], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [ + "skills[1]: a skill id cannot be blank." + ] + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "skills[1]: a skill id cannot be blank." + }, + { + "name": "web-search-snake-case-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCndlYl9zZWFyY2g6IHRydWUKLS0tCgojIFNhbXBsZSBBZ2VudAoKIyMgSW5zdHJ1Y3Rpb25zCgpBbnN3ZXIuCg==", + "bytes": 154, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "web_search": true + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": true, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "web-search-string-true-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCndlYlNlYXJjaDogInRydWUiCi0tLQoKIyBTYW1wbGUgQWdlbnQKCiMjIEluc3RydWN0aW9ucwoKQW5zd2VyLgo=", + "bytes": 155, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "webSearch": "true" + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "version-quoted-integer-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnZlcnNpb246ICIzIgotLS0KCiMgU2FtcGxlIEFnZW50CgojIyBJbnN0cnVjdGlvbnMKCkFuc3dlci4K", + "bytes": 150, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "version": "3" + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 3, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "version-zero-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnZlcnNpb246IDAKLS0tCgojIFNhbXBsZSBBZ2VudAoKIyMgSW5zdHJ1Y3Rpb25zCgpBbnN3ZXIuCg==", + "bytes": 148, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "version": 0 + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [ + "version: `0` is not a whole number of 1 or more." + ] + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "version: `0` is not a whole number of 1 or more." + }, + { + "name": "version-empty-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnZlcnNpb246Ci0tLQoKIyBTYW1wbGUgQWdlbnQKCiMjIEluc3RydWN0aW9ucwoKQW5zd2VyLgo=", + "bytes": 146, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "version": null + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "crlf-inside-frontmatter-only", + "rawBase64": "LS0tDQppZDogc2FtcGxlLXNraWxsDQpuYW1lOiBTYW1wbGUgU2tpbGwNCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4NCnBhZ2VzOg0KICAtIGNhbmRpZGF0ZXMNCnN0YXR1czogYWN0aXZlDQphY3Rpb25zOg0KICAtIG5hdmlnYXRlX3RvX2NhbmRpZGF0ZXMKLS0tCgojIFNhbXBsZSBTa2lsbAoKIyMgUHVycG9zZQoKLSBSZWFkIHRoZSBwaXBlbGluZS4KCiMjIENhcGFiaWxpdGllcwoKLSBTdW1tYXJpemUgY2FuZGlkYXRlcy4K", + "bytes": 246, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ], + "status": "active", + "actions": [ + "navigate_to_candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline.\n\n## Capabilities\n\n- Summarize candidates." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [ + "navigate_to_candidates" + ], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "body-whitespace-only-after-fence", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCi0tLQogICAKCQo=", + "bytes": 101, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ] + }, + "body": "" + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "description-whitespace-only", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiAiICAgIgpwYWdlczoKICAtIGNhbmRpZGF0ZXMKLS0tCgojIFNhbXBsZSBTa2lsbAoKIyMgUHVycG9zZQoKLSBSZWFkIHRoZSBwaXBlbGluZS4K", + "bytes": 135, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": " ", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": " ", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "permissions-null-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnBlcm1pc3Npb25zOgotLS0KCiMgU2FtcGxlIEFnZW50CgojIyBJbnN0cnVjdGlvbnMKCkFuc3dlci4K", + "bytes": 150, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "permissions": null + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "permissions-people-bad-role-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnBlcm1pc3Npb25zOgogIGFjY2Vzczogc3BlY2lmaWMKICBwZW9wbGU6CiAgICAtIHVzZXI6IGFAYi5jCiAgICAgIHJvbGU6IG92ZXJsb3JkCi0tLQoKIyBTYW1wbGUgQWdlbnQKCiMjIEluc3RydWN0aW9ucwoKQW5zd2VyLgo=", + "bytes": 218, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "permissions": { + "access": "specific", + "people": [ + { + "user": "a@b.c", + "role": "overlord" + } + ] + } + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "specific", + "people": [] + }, + "errors": [ + "permissions.people[0]: `overlord` is not a role. Use one of manager, editor, viewer." + ] + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "permissions.people[0]: `overlord` is not a role. Use one of manager, editor, viewer." + }, + { + "name": "id-omitted-unnamed", + "rawBase64": "LS0tCmRlc2NyaXB0aW9uOiBBIHNhbXBsZS4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCg==", + "bytes": 103, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "description": "A sample.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "custom", + "name": "Untitled skill", + "description": "A sample.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "id-omitted-named", + "rawBase64": "LS0tCm5hbWU6IFNhbXBsZSBTa2lsbApkZXNjcmlwdGlvbjogQSBzYW1wbGUuCnBhZ2VzOgogIC0gY2FuZGlkYXRlcwotLS0KCiMgU2FtcGxlIFNraWxsCgojIyBQdXJwb3NlCgotIFJlYWQgdGhlIHBpcGVsaW5lLgo=", + "bytes": 122, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "name": "Sample Skill", + "description": "A sample.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "id-omitted-unnamed-agent", + "rawBase64": "LS0tCmRlc2NyaXB0aW9uOiBBIHNhbXBsZS4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCi0tLQoKIyBTYW1wbGUgQWdlbnQKCiMjIEluc3RydWN0aW9ucwoKQW5zd2VyLgo=", + "bytes": 95, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "description": "A sample.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "custom", + "name": "Untitled agent", + "description": "A sample.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "The frontmatter needs a `name`." + }, + { + "name": "id-omitted-named-agent", + "rawBase64": "LS0tCm5hbWU6IFNhbXBsZSBBZ2VudApkZXNjcmlwdGlvbjogQSBzYW1wbGUuCnBhZ2VzOgogIC0gY2FuZGlkYXRlcwotLS0KCiMgU2FtcGxlIEFnZW50CgojIyBJbnN0cnVjdGlvbnMKCkFuc3dlci4K", + "bytes": 114, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "name": "Sample Agent", + "description": "A sample.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "id-omitted-name-unsluggable", + "rawBase64": "LS0tCm5hbWU6ICIhISEiCmRlc2NyaXB0aW9uOiBBIHNhbXBsZS4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCg==", + "bytes": 115, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "name": "!!!", + "description": "A sample.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "custom", + "name": "!!!", + "description": "A sample.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "!!!" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "name-numeric", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogNDIKZGVzY3JpcHRpb246IEEgc2FtcGxlLgpwYWdlczoKICAtIGNhbmRpZGF0ZXMKLS0tCgojIFNhbXBsZSBTa2lsbAoKIyMgUHVycG9zZQoKLSBSZWFkIHRoZSBwaXBlbGluZS4K", + "bytes": 129, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": 42, + "description": "A sample.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": 42, + "description": "A sample.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "42" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "name-boolean", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogdHJ1ZQpkZXNjcmlwdGlvbjogQSBzYW1wbGUuCnBhZ2VzOgogIC0gY2FuZGlkYXRlcwotLS0KCiMgU2FtcGxlIFNraWxsCgojIyBQdXJwb3NlCgotIFJlYWQgdGhlIHBpcGVsaW5lLgo=", + "bytes": 131, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": true, + "description": "A sample.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": true, + "description": "A sample.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "true" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "description-list", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOgogIC0gb25lCiAgLSB0d28KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCg==", + "bytes": 145, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": [ + "one", + "two" + ], + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": [ + "one", + "two" + ], + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "description-numeric", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiA3CnBhZ2VzOgogIC0gY2FuZGlkYXRlcwotLS0KCiMgU2FtcGxlIFNraWxsCgojIyBQdXJwb3NlCgotIFJlYWQgdGhlIHBpcGVsaW5lLgo=", + "bytes": 131, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": 7, + "pages": [ + "candidates" + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": 7, + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "pages-numeric-entry", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZS4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCiAgLSA1Ci0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCg==", + "bytes": 145, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "pages": [ + "candidates", + 5 + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "status": "active", + "pages": [ + "candidates", + 5 + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "Unsupported page: 5. Supported pages: control-center, positions, create-position, candidates, hired-history, talent-pool, krow-forge, analytics, activity, workspace-agent-configure, settings, workspace, workspace-agents, workspace-skills, workspace-skill-configure, skill-development, profile, candidates-analysis." + }, + { + "name": "pages-mapping-entry", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZS4KcGFnZXM6CiAgLSBwYWdlOiBjYW5kaWRhdGVzCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCg==", + "bytes": 145, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "pages": [ + { + "page": "candidates" + } + ] + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample.", + "status": "active", + "pages": [ + { + "page": "candidates" + } + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": false, + "rejection": "Unsupported page: [object Object]. Supported pages: control-center, positions, create-position, candidates, hired-history, talent-pool, krow-forge, analytics, activity, workspace-agent-configure, settings, workspace, workspace-agents, workspace-skills, workspace-skill-configure, skill-development, profile, candidates-analysis." + }, + { + "name": "trigger-list-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnRyaWdnZXI6CiAgLSBvbmUKICAtIHR3bwotLS0KCiMgU2FtcGxlIEFnZW50CgojIyBJbnN0cnVjdGlvbnMKCkFuc3dlci4K", + "bytes": 162, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "trigger": [ + "one", + "two" + ] + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "one,two", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "name-list-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZToKICAtIGEKICAtIGIKZGVzY3JpcHRpb246IEEgc2FtcGxlLgpwYWdlczoKICAtIGNhbmRpZGF0ZXMKLS0tCgojIFNhbXBsZSBBZ2VudAoKIyMgSW5zdHJ1Y3Rpb25zCgpBbnN3ZXIuCg==", + "bytes": 130, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": [ + "a", + "b" + ], + "description": "A sample.", + "pages": [ + "candidates" + ] + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": [ + "a", + "b" + ], + "description": "A sample.", + "status": "draft", + "version": 1, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "category-numeric", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtc2tpbGwKbmFtZTogU2FtcGxlIFNraWxsCmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBza2lsbC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCmNhdGVnb3J5OiAzCi0tLQoKIyBTYW1wbGUgU2tpbGwKCiMjIFB1cnBvc2UKCi0gUmVhZCB0aGUgcGlwZWxpbmUuCg==", + "bytes": 157, + "kind": "skill", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "pages": [ + "candidates" + ], + "category": 3 + }, + "body": "# Sample Skill\n\n## Purpose\n\n- Read the pipeline." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-skill", + "name": "Sample Skill", + "description": "A sample skill.", + "status": "active", + "pages": [ + "candidates" + ], + "kind": "assistant", + "category": "", + "actions": [], + "triggers": [ + "sample skill" + ], + "declaredTriggers": false, + "prompt": null, + "facets": [ + "owliver" + ], + "skillId": null + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "version-above-int32-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnZlcnNpb246IDMwMDAwMDAwMDAKLS0tCgojIFNhbXBsZSBBZ2VudAoKIyMgSW5zdHJ1Y3Rpb25zCgpBbnN3ZXIuCg==", + "bytes": 157, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "version": 3000000000 + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 3000000000, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + }, + { + "name": "version-at-int32-agent", + "rawBase64": "LS0tCmlkOiBzYW1wbGUtYWdlbnQKbmFtZTogU2FtcGxlIEFnZW50CmRlc2NyaXB0aW9uOiBBIHNhbXBsZSBhZ2VudC4KcGFnZXM6CiAgLSBjYW5kaWRhdGVzCnZlcnNpb246IDIxNDc0ODM2NDcKLS0tCgojIFNhbXBsZSBBZ2VudAoKIyMgSW5zdHJ1Y3Rpb25zCgpBbnN3ZXIuCg==", + "bytes": 157, + "kind": "agent", + "hasFrontmatter": true, + "frontmatter": { + "ok": true, + "data": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "pages": [ + "candidates" + ], + "version": 2147483647 + }, + "body": "# Sample Agent\n\n## Instructions\n\nAnswer." + }, + "parse": { + "ok": true + }, + "normalized": { + "id": "sample-agent", + "name": "Sample Agent", + "description": "A sample agent.", + "status": "draft", + "version": 2147483647, + "pages": [ + "candidates" + ], + "icon": "owliver", + "reasoning": "balanced", + "trigger": "", + "webSearch": false, + "skills": [], + "subagents": [], + "starters": [], + "permissions": { + "owner": "", + "access": "all", + "people": [] + }, + "errors": [] + }, + "markdownVerbatim": true, + "accepted": true, + "rejection": null + } + ] +} diff --git a/go-api/internal/definition/vocabulary.go b/go-api/internal/definition/vocabulary.go new file mode 100644 index 0000000..cd4aeaa --- /dev/null +++ b/go-api/internal/definition/vocabulary.go @@ -0,0 +1,184 @@ +package definition + +import "strings" + +// The closed vocabulary a definition is allowed to name. +// +// Every table here is a transcription of a frontend table, and the conformance +// suite asserts each one against the vocabulary the JavaScript actually +// exports (testdata/oracle.json, `vocabulary`) — so a page added to +// surfaces.js or an icon added to vocabulary.js fails a test here rather than +// silently making the two ends disagree about what is valid. +// +// Nothing is looked up dynamically and nothing is constructed: a definition +// names a key and this file answers whether that key exists. + +// pageSurfaces mirrors SKILL_SURFACES in src/lib/skills/surfaces.js — id first, +// then the alternative spellings an author may use for it. +var pageSurfaces = []struct { + ID string + Aliases []string +}{ + {ID: "control-center"}, + {ID: "positions"}, + {ID: "create-position", Aliases: []string{"new-position"}}, + {ID: "candidates"}, + {ID: "hired-history", Aliases: []string{"hired"}}, + {ID: "talent-pool"}, + {ID: "krow-forge", Aliases: []string{"university", "forge"}}, + {ID: "analytics"}, + {ID: "activity"}, + {ID: "workspace-agent-configure"}, + {ID: "settings"}, + {ID: "workspace"}, + {ID: "workspace-agents"}, + {ID: "workspace-skills"}, + {ID: "workspace-skill-configure"}, + {ID: "skill-development"}, + {ID: "profile"}, + {ID: "candidates-analysis"}, +} + +// surfaceByKey resolves every spelling — canonical or alias — to its canonical +// id. +var surfaceByKey = func() map[string]string { + m := map[string]string{} + for _, s := range pageSurfaces { + m[s.ID] = s.ID + for _, a := range s.Aliases { + m[a] = s.ID + } + } + return m +}() + +// SupportedPages is every canonical page id, in declaration order. The order is +// the order the frontend lists them in when it refuses an unsupported page, and +// that message is compared byte for byte. +var SupportedPages = func() []string { + out := make([]string, len(pageSurfaces)) + for i, s := range pageSurfaces { + out[i] = s.ID + } + return out +}() + +// normalizeKey is surfaces.js's own: trimmed, lower-cased, with spaces and +// underscores read as dashes. `Talent Pool` and `talent_pool` both reach +// `talent-pool`; the canonical keys never widen, only what an author may type +// to reach them. +func normalizeKey(page any) string { + s := strings.ToLower(jsTrim(jsString(page))) + if page == nil { + s = "" + } + return strings.Map(func(r rune) rune { + if r == ' ' || r == '_' || jsIsSpace(r) { + return '-' + } + return r + }, s) +} + +// SurfaceExists reports whether a declared page name refers to a real surface. +func SurfaceExists(page any) bool { + _, ok := surfaceByKey[normalizeKey(page)] + return ok +} + +// CanonicalPage is the canonical id a declared page name refers to, or "". +func CanonicalPage(page any) string { return surfaceByKey[normalizeKey(page)] } + +/* ── Agent vocabulary — src/lib/agents/vocabulary.js ─────────────────────── */ + +var ( + AgentStatuses = []string{"draft", "published", "archived"} + ReasoningModes = []string{"fast", "balanced", "deep"} + KnowledgeKinds = []string{"note", "link", "skill-reference"} + AgentAccess = []string{"all", "specific"} + PermissionRole = []string{"manager", "editor", "viewer"} + AgentIcons = []string{ + "owliver", "sparkles", "briefcase", "users", "user-check", + "layers", "graduation-cap", "bar-chart", "activity", "shield", + } +) + +const ( + DefaultAgentStatus = "draft" + DefaultReasoning = "balanced" + DefaultAgentIcon = "owliver" + DefaultKnowledgeKind = "note" + DefaultAgentAccess = "all" + DefaultPermission = "viewer" +) + +func contains(list []string, want string) bool { + for _, v := range list { + if v == want { + return true + } + } + return false +} + +/* ── Skill vocabulary ────────────────────────────────────────────────────── */ + +// SkillStatuses is the whole of a skill's lifecycle. There is no version and no +// publish step: migration 000005 states the same two values. +var SkillStatuses = []string{"active", "inactive"} + +// levelHeadings are the rungs a workforce skill may define, and the reason a +// definition is read as workforce rather than assistant. Order is the ladder's. +var levelHeadings = []string{"Beginner", "Intermediate", "Advanced", "Expert"} + +// slugify is uiConfig.js's, used to derive an id from a name. +// +// String(value || '').toLowerCase().trim() +// .replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') +// +// The lower-casing happens BEFORE the class replacement, so an upper-case +// letter becomes itself rather than a dash. +func slugify(value any) string { + if !jsTruthy(value) { + return "" + } + s := jsTrim(strings.ToLower(jsString(value))) + + var b strings.Builder + dash := false + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + dash = false + continue + } + if !dash { + b.WriteByte('-') + dash = true + } + } + out := b.String() + out = strings.TrimPrefix(out, "-") + out = strings.TrimSuffix(out, "-") + return out +} + +// isDefinitionID is the id format the frontend validator enforces and the +// definition_id CHECK in migration 000005 restates. +func isDefinitionID(id string) bool { + if id == "" { + return false + } + for i, r := range id { + lower := r >= 'a' && r <= 'z' + digit := r >= '0' && r <= '9' + if lower || digit { + continue + } + if r == '-' && i > 0 { + continue + } + return false + } + return true +} diff --git a/go-api/internal/definition/yaml.go b/go-api/internal/definition/yaml.go new file mode 100644 index 0000000..c30b545 --- /dev/null +++ b/go-api/internal/definition/yaml.go @@ -0,0 +1,362 @@ +package definition + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +// The YAML subset a Krow definition is allowed to use. +// +// A line-for-line port of the frontend's src/lib/skills/yaml.js. That file is +// the specification and this is the second implementation of it, so every +// decision below is made because the JavaScript makes it — including the ones +// a YAML library would make differently. +// +// Supported, and nothing else: +// +// - block maps and block sequences, nested to any depth +// - scalars: strings, integers, floats, booleans, null +// - quoted strings, for values containing `:` or `#` +// - `- key: value`, a mapping whose first key sits on the dash +// - `#` comments, and blank lines +// +// Anchors, aliases, merge keys, multi-document files, flow mappings, flow +// sequences, block scalars and tags are NOT supported, and are not silently +// half-read: an unparseable line is an error carrying the line number, so a +// definition either means what it says or is refused with somewhere to look. +// +// No dependency. A general YAML library would accept a much larger language +// than the frontend does, and every construct it accepted and the frontend did +// not would be a definition the backend stores and the editor cannot read — +// exactly the failure this package exists to prevent. The subset is small +// enough to port exactly, so it is ported exactly. +// +// Nothing here evaluates anything. There is no reflection, no template, no +// code path from a definition to execution of any kind: a definition is +// configuration, and this is the boundary that keeps it configuration. + +// Error is a definition that could not be read, carrying the line it failed on. +// +// Line is 1-based and counts lines of FRONTMATTER, not of the file — which is +// what the JavaScript reports, because parseYaml is handed the fenced block +// rather than the document. Reproduced rather than improved: an author who +// sees one message in the editor and another from the API is being told about +// two different problems. +type Error struct { + Line int + Message string +} + +func (e *Error) Error() string { return e.Message } + +// errIndent and errPair are the two failures the subset has, worded exactly as +// the frontend words them. +func errIndent(line int) *Error { + return &Error{Line: line, Message: fmt.Sprintf("Unexpected indentation on line %d", line)} +} + +func errPair(line int, content string) *Error { + return &Error{Line: line, Message: fmt.Sprintf("Line %d is not `key: value`: %s", line, content)} +} + +// keyPair matches `key: value` and is the only shape a mapping entry may take. +// The key alphabet is the frontend's: letters, digits, underscore, dot, dash — +// which is why `my key: value` is a refusal rather than a key with a space. +// jsSpaceClass is the JavaScript `\s` character class. Go's own `\s` is +// ASCII-only, and the difference is reachable: a non-breaking space after the +// colon is whitespace to the editor's parser and would be part of the value +// here. +const jsSpaceClass = `[\t\n\v\f\r \x{00A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}]` + +var keyPair = regexp.MustCompile(`^([A-Za-z0-9_.-]+):` + jsSpaceClass + `*([\s\S]*)$`) + +var ( + reInt = regexp.MustCompile(`^-?[0-9]+$`) + reFloat = regexp.MustCompile(`^-?[0-9]*\.[0-9]+$`) +) + +// line is one significant line, reduced to what the parser needs to decide. +type line struct { + number int // 1-based, within the frontmatter block + indent int // leading whitespace, tabs counted as two + content string +} + +// readLines drops blank lines and whole-line comments, and measures what is +// left. +// +// Indentation is counted in code points with a tab worth two spaces, which is +// what the JavaScript does and is why a tab-indented sequence sits at the same +// depth as a two-space one. +func readLines(source string) []line { + out := []line{} + for i, text := range strings.Split(source, "\n") { + trimmed := jsTrim(text) + if trimmed == "" { + continue + } + // A whole-line comment: `^\s*#`. + if strings.HasPrefix(jsTrimStart(text), "#") { + continue + } + indent := 0 + for _, r := range text { + if !jsIsSpace(r) { + break + } + if r == '\t' { + indent += 2 + continue + } + indent++ + } + out = append(out, line{number: i + 1, indent: indent, content: trimmed}) + } + return out +} + +// quoted matches a scalar wrapped in one kind of quote, end to end. +// +// Greedy and anchored at both ends, as in the frontend: `"a" "b"` is therefore +// ONE quoted string whose content is `a" "b`, not two. That is a strange +// reading, and it is the reading the editor gives, so it is the reading here. +func quotedScalar(value string) (string, bool) { + if len(value) < 2 { + return "", false + } + q := value[0] + if q != '\'' && q != '"' { + return "", false + } + if value[len(value)-1] != q { + return "", false + } + inner := value[1 : len(value)-1] + // The only escape the subset has: a doubled quote is one quote. + return strings.ReplaceAll(inner, string([]byte{q, q}), string(q)), true +} + +// stripTrailingComment removes an unquoted trailing `#` comment. +// +// `\s+#.*$` applied once, leftmost — so `ops # a # b` loses everything from +// the first spaced hash, and `ops#1` loses nothing, because a hash inside a +// word is part of the word. +func stripTrailingComment(value string) string { + runes := []rune(value) + for i := 0; i < len(runes); i++ { + if !jsIsSpace(runes[i]) { + continue + } + j := i + for j < len(runes) && jsIsSpace(runes[j]) { + j++ + } + if j < len(runes) && runes[j] == '#' { + return string(runes[:i]) + } + i = j - 1 + } + return value +} + +// toScalar reads one written value: `true`, `false`, `null`, a number, a +// quoted string, or the string as written. +func toScalar(raw string) any { + value := jsTrim(raw) + + switch value { + case "", "~", "null": + return nil + case "true": + return true + case "false": + return false + } + + // Quoted: taken literally, which is how a value containing `:` or `#` is + // written. No escape processing beyond the doubled quote. + if inner, ok := quotedScalar(value); ok { + return inner + } + + if reInt.MatchString(value) || reFloat.MatchString(value) { + if f, err := strconv.ParseFloat(value, 64); err == nil { + return f + } + } + + return jsTrim(stripTrailingComment(value)) +} + +// cursor is shared down the recursion so a child consumes the lines it owns. +type cursor struct{ i int } + +// parseBlock reads one block at indent or deeper. +// +// Map or sequence depending on what the first line at this level is, which is +// how YAML itself decides. +func parseBlock(lines []line, c *cursor, indent int) (any, *Error) { + if c.i >= len(lines) { + return nil, nil + } + first := lines[c.i] + if strings.HasPrefix(first.content, "- ") || first.content == "-" { + return parseSequence(lines, c, indent) + } + return parseMapping(lines, c, indent) +} + +// dashPrefix is the `-` and the whitespace after it, as `^-\s*` consumes them. +func dashPrefix(content string) int { + if !strings.HasPrefix(content, "-") { + return 0 + } + n := 1 + for _, r := range content[1:] { + if !jsIsSpace(r) { + break + } + n += len(string(r)) + } + return n +} + +func parseSequence(lines []line, c *cursor, indent int) (any, *Error) { + out := []any{} + + for c.i < len(lines) { + cur := lines[c.i] + if cur.indent < indent { + break + } + if cur.indent > indent { + return nil, errIndent(cur.number) + } + if !strings.HasPrefix(cur.content, "-") { + break + } + + cut := dashPrefix(cur.content) + rest := cur.content[cut:] + c.i++ + + if rest == "" { + // `-` alone: the item is the indented block beneath it. + if c.i < len(lines) && lines[c.i].indent > indent { + item, err := parseBlock(lines, c, lines[c.i].indent) + if err != nil { + return nil, err + } + out = append(out, item) + continue + } + out = append(out, nil) + continue + } + + // `- key: value` opens a mapping whose first key sits on the dash. The + // remaining keys are indented to where that key started. + if m := keyPair.FindStringSubmatch(rest); m != nil { + keyIndent := indent + cut + item := map[string]any{} + key, value := m[1], m[2] + + if value == "" && c.i < len(lines) && lines[c.i].indent > indent { + block, err := parseBlock(lines, c, lines[c.i].indent) + if err != nil { + return nil, err + } + item[key] = block + } else { + item[key] = toScalar(value) + } + + for c.i < len(lines) && lines[c.i].indent == keyIndent && + !strings.HasPrefix(lines[c.i].content, "- ") { + more, err := parseMapping(lines, c, keyIndent) + if err != nil { + return nil, err + } + if m, ok := more.(map[string]any); ok { + for k, v := range m { + item[k] = v + } + } + } + out = append(out, item) + continue + } + + out = append(out, toScalar(rest)) + } + + return out, nil +} + +func parseMapping(lines []line, c *cursor, indent int) (any, *Error) { + out := map[string]any{} + + for c.i < len(lines) { + cur := lines[c.i] + if cur.indent < indent { + break + } + if cur.indent > indent { + return nil, errIndent(cur.number) + } + if strings.HasPrefix(cur.content, "- ") { + break + } + + m := keyPair.FindStringSubmatch(cur.content) + if m == nil { + return nil, errPair(cur.number, cur.content) + } + + key, value := m[1], m[2] + c.i++ + + if value != "" { + out[key] = toScalar(value) + continue + } + + // An empty value means the value is the block below — or nothing. + if c.i < len(lines) && lines[c.i].indent > indent { + block, err := parseBlock(lines, c, lines[c.i].indent) + if err != nil { + return nil, err + } + out[key] = block + continue + } + out[key] = nil + } + + return out, nil +} + +// ParseYAML reads one document of the subset as plain data. +// +// Returns map[string]any, []any, or the empty map for an empty document. +// Anything it cannot read is an error rather than a guess, so a malformed +// definition is reported to its author instead of being registered in a shape +// nobody intended. +func ParseYAML(source string) (any, error) { + lines := readLines(source) + if len(lines) == 0 { + return map[string]any{}, nil + } + + c := &cursor{} + value, err := parseBlock(lines, c, lines[0].indent) + if err != nil { + return nil, err + } + if c.i < len(lines) { + return nil, errIndent(lines[c.i].number) + } + return value, nil +} diff --git a/go-api/internal/domain/definitions_schema_test.go b/go-api/internal/domain/definitions_schema_test.go new file mode 100644 index 0000000..36a98eb --- /dev/null +++ b/go-api/internal/domain/definitions_schema_test.go @@ -0,0 +1,748 @@ +package domain_test + +import ( + "context" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/krow/krow-backend/go-api/internal/testutil" +) + +// Phase 4C — the shape of the authored-definition tables. +// +// These test the MIGRATION, not any Go code: there is no agent or skill package +// yet, and there deliberately is not one until Phase 4D. What is under test is +// whether the database refuses the things it is supposed to refuse. +// +// An external test package (`domain_test`) rather than `package domain`, +// because testutil imports seeder which imports domain — reachable from an +// external test binary, an import cycle from an internal one. + +const ( + agents = "agent_definitions" + skills = "skill_definitions" +) + +// fixture is a migrated sandbox with one organization and two users. +type fixture struct { + pool *pgxpool.Pool + ctx context.Context + orgID string + alice string + bob string +} + +func newFixture(t *testing.T, label string) *fixture { + t.Helper() + ctx := context.Background() + pool := testutil.Sandbox(t, label) + testutil.ApplyAllMigrations(ctx, t, pool) + + f := &fixture{pool: pool, ctx: ctx} + if err := pool.QueryRow(ctx, + `INSERT INTO organizations (name, slug) VALUES ('Defs Org','defs-org') RETURNING id::text`). + Scan(&f.orgID); err != nil { + t.Fatalf("create organization: %v", err) + } + f.alice = f.newUser(t, "alice@example.test") + f.bob = f.newUser(t, "bob@example.test") + return f +} + +func (f *fixture) newUser(t *testing.T, email string) string { + t.Helper() + var id string + if err := f.pool.QueryRow(f.ctx, + `INSERT INTO users (org_id, email, full_name) VALUES ($1::uuid, $2::citext, $3) RETURNING id::text`, + f.orgID, email, email).Scan(&id); err != nil { + t.Fatalf("create user %s: %v", email, err) + } + return id +} + +// row is one candidate definition. Any field may be made deliberately wrong. +type row struct { + table string + defID string + orgID string + visibility string + owner *string + createdBy *string + markdown string + status string + version *int +} + +func (f *fixture) insert(r row) (string, error) { + cols := []string{"definition_id", "org_id", "visibility", "owner_user_id", "created_by", "markdown"} + vals := []string{"$1::text", "$2::uuid", "$3::text", "$4::uuid", "$5::uuid", "$6::text"} + args := []any{r.defID, r.orgID, r.visibility, r.owner, r.createdBy, r.markdown} + + if r.status != "" { + cols, vals = append(cols, "status"), append(vals, "$7::text") + args = append(args, r.status) + } + if r.version != nil { + cols = append(cols, "version") + vals = append(vals, "$"+itoa(len(args)+1)+"::integer") + args = append(args, *r.version) + } + + var id string + err := f.pool.QueryRow(f.ctx, + "INSERT INTO "+r.table+" ("+strings.Join(cols, ", ")+") VALUES ("+ + strings.Join(vals, ", ")+") RETURNING id::text", args...).Scan(&id) + return id, err +} + +func itoa(n int) string { + if n < 10 { + return string(rune('0' + n)) + } + return string(rune('0'+n/10)) + string(rune('0'+n%10)) +} + +// personal and organization build a valid row of each tier, so a test can +// change exactly one thing and see whether the database notices. +func (f *fixture) personal(table, defID, owner string) row { + return row{table: table, defID: defID, orgID: f.orgID, visibility: "personal", + owner: &owner, createdBy: &owner, markdown: "---\nid: " + defID + "\n---\n"} +} + +func (f *fixture) organization(table, defID, author string) row { + return row{table: table, defID: defID, orgID: f.orgID, visibility: "organization", + owner: nil, createdBy: &author, markdown: "---\nid: " + defID + "\n---\n"} +} + +func mustInsert(t *testing.T, f *fixture, r row) string { + t.Helper() + id, err := f.insert(r) + if err != nil { + t.Fatalf("a valid %s row was refused: %v", r.table, err) + } + return id +} + +func refused(t *testing.T, f *fixture, r row, wantConstraint, why string) { + t.Helper() + _, err := f.insert(r) + if err == nil { + t.Fatalf("%s: the row was ACCEPTED — %s", r.table, why) + } + if wantConstraint != "" && !strings.Contains(err.Error(), wantConstraint) { + t.Errorf("%s: refused by %v, want the %s constraint", r.table, err, wantConstraint) + } +} + +/* ── 1, 2. The ownership invariant ──────────────────────────────────────── */ + +func TestVisibilityRequiresMatchingOwnership(t *testing.T) { + f := newFixture(t, "defs_ownership") + + for _, table := range []string{agents, skills} { + t.Run(table, func(t *testing.T) { + // The two valid shapes. + mustInsert(t, f, f.personal(table, "valid-personal", f.alice)) + mustInsert(t, f, f.organization(table, "valid-org", f.alice)) + + // 1. A personal definition with no owner belongs to nobody. + bad := f.personal(table, "no-owner", f.alice) + bad.owner = nil + refused(t, f, bad, "visibility_owner", + "a personal definition must have an owner") + + // 2. An organization definition with an owner is two answers to + // "whose is this", which is one too many. + bad = f.organization(table, "with-owner", f.alice) + bad.owner = &f.alice + refused(t, f, bad, "visibility_owner", + "an organization definition must not have an owner") + + // And an unrecognised tier is not a tier. + bad = f.personal(table, "bad-tier", f.alice) + bad.visibility = "public" + refused(t, f, bad, "visibility_check", "`public` is not a visibility") + }) + } +} + +/* ── 3. definition_id format ────────────────────────────────────────────── */ + +// The same rule the frontend validator enforces, restated in the database so a +// caller that bypasses the application cannot store an id the registry could +// never address. +func TestDefinitionIDFormat(t *testing.T) { + f := newFixture(t, "defs_idformat") + + valid := []string{"a", "board", "krow-workforce-agent", "x1", "a-1-b", "0abc"} + invalid := map[string]string{ + "leading dash": "-board", + "upper case": "Board", + "underscore": "my_skill", + "space": "my skill", + "trailing dot": "board.", + "empty": "", + "slash": "custom/board", + "unicode": "bòard", + "sql-ish": "a'; DROP TABLE users; --", + "newline": "board\nx", + } + + for _, table := range []string{agents, skills} { + t.Run(table, func(t *testing.T) { + for _, id := range valid { + if _, err := f.insert(f.personal(table, id, f.alice)); err != nil { + t.Errorf("valid id %q was refused: %v", id, err) + } + } + for name, id := range invalid { + bad := f.personal(table, id, f.bob) + refused(t, f, bad, "definition_id_format", "id "+name+" ("+id+") is not a valid id") + } + }) + } +} + +/* ── 4, 5, 6, 7. Status vocabularies and version ────────────────────────── */ + +func TestAgentStatusAndVersion(t *testing.T) { + f := newFixture(t, "defs_agentstatus") + + // 4. The three agent statuses, and nothing else. + for _, status := range []string{"draft", "published", "archived"} { + r := f.personal(agents, "s-"+status, f.alice) + r.status = status + mustInsert(t, f, r) + } + for _, status := range []string{"active", "inactive", "live", "DRAFT", ""} { + r := f.personal(agents, "bad-status", f.bob) + r.status = status + if status == "" { + continue // an omitted status takes the default; tested below + } + refused(t, f, r, "status_check", "`"+status+"` is not an agent status") + } + + // The default is draft: creating an agent must never publish it. + id := mustInsert(t, f, f.personal(agents, "defaulted", f.bob)) + var status string + var version int + if err := f.pool.QueryRow(f.ctx, + `SELECT status, version FROM agent_definitions WHERE id = $1::uuid`, id). + Scan(&status, &version); err != nil { + t.Fatalf("read back: %v", err) + } + if status != "draft" { + t.Errorf("default status = %q, want draft", status) + } + if version != 1 { + t.Errorf("default version = %d, want 1", version) + } + + // 6. A version is a whole number of 1 or more. + for _, v := range []int{0, -1, -100} { + r := f.personal(agents, "bad-version", f.bob) + r.version = &v + refused(t, f, r, "version_check", "version must be at least 1") + } + for _, v := range []int{1, 2, 9999} { + r := f.personal(agents, "v-ok", f.alice) + r.version = &v + r.defID = "v-ok-" + itoa(v%100) + if _, err := f.insert(r); err != nil { + t.Errorf("version %d was refused: %v", v, err) + } + } +} + +func TestSkillStatusAndNoVersion(t *testing.T) { + f := newFixture(t, "defs_skillstatus") + + // 5. The two skill statuses, and nothing else. + for _, status := range []string{"active", "inactive"} { + r := f.personal(skills, "s-"+status, f.alice) + r.status = status + mustInsert(t, f, r) + } + for _, status := range []string{"draft", "published", "archived", "ACTIVE"} { + r := f.personal(skills, "bad-status", f.bob) + r.status = status + refused(t, f, r, "status_check", "`"+status+"` is not a skill status") + } + + // The default is active — a skill is on unless somebody turns it off. + id := mustInsert(t, f, f.personal(skills, "defaulted", f.bob)) + var status string + if err := f.pool.QueryRow(f.ctx, + `SELECT status FROM skill_definitions WHERE id = $1::uuid`, id).Scan(&status); err != nil { + t.Fatalf("read back: %v", err) + } + if status != "active" { + t.Errorf("default status = %q, want active", status) + } + + // 7. Skills have NO version. The frontend has no notion of one, so the + // column must not exist — inventing it "for symmetry" would create a field + // nothing can set and nothing can mean. + var exists int + if err := f.pool.QueryRow(f.ctx, + `SELECT count(*)::int FROM information_schema.columns + WHERE table_schema='public' AND table_name='skill_definitions' AND column_name='version'`). + Scan(&exists); err != nil { + t.Fatalf("look for a version column: %v", err) + } + if exists != 0 { + t.Error("skill_definitions has a version column; skills have no version concept") + } +} + +/* ── 8. Markdown bound ──────────────────────────────────────────────────── */ + +func TestMarkdownSizeBound(t *testing.T) { + f := newFixture(t, "defs_markdown") + + for _, table := range []string{agents, skills} { + t.Run(table, func(t *testing.T) { + // The largest definition shipped with the product is 3,156 bytes, + // so anything realistic is far inside the bound. + ok := f.personal(table, "big-but-fine", f.alice) + ok.markdown = strings.Repeat("x", 65536) + mustInsert(t, f, ok) + + over := f.personal(table, "too-big", f.bob) + over.markdown = strings.Repeat("x", 65537) + refused(t, f, over, "markdown_size", "a definition over the size bound") + + empty := f.personal(table, "empty-md", f.bob) + empty.markdown = "" + refused(t, f, empty, "markdown_size", "an empty definition cannot parse") + }) + } +} + +// The Markdown is stored byte-for-byte. A definition has to survive a round +// trip to a .md file on disk, so anything that rewrote it here — trimming, +// newline normalisation, unicode folding — would break that. +func TestMarkdownIsStoredVerbatim(t *testing.T) { + f := newFixture(t, "defs_verbatim") + + // A BOM, CRLF endings, trailing spaces and a tab — exactly the four things + // normalizeDefinition exists to tolerate. The database must not "help" by + // removing any of them: normalising is the parser's job, on read. + source := "\ufeff---\r\nid: verbatim\r\nname: Verbatim\r\n---\r\n\r\n# Verbatim \r\n\ttabbed\n" + r := f.personal(agents, "verbatim", f.alice) + r.markdown = source + id := mustInsert(t, f, r) + + var stored string + if err := f.pool.QueryRow(f.ctx, + `SELECT markdown FROM agent_definitions WHERE id = $1::uuid`, id).Scan(&stored); err != nil { + t.Fatalf("read back: %v", err) + } + if stored != source { + t.Errorf("the stored Markdown differs from what was written:\n in %q\n out %q", source, stored) + } +} + +/* ── 9, 10, 11, 12. Foreign keys and deletion ───────────────────────────── */ + +func TestForeignKeysAndDeleteBehaviour(t *testing.T) { + f := newFixture(t, "defs_fk") + missing := "00000000-0000-0000-0000-000000000000" + + for _, table := range []string{agents, skills} { + t.Run(table+"/rejects unknown references", func(t *testing.T) { + // 9. An organization that does not exist. + bad := f.personal(table, "bad-org", f.alice) + bad.orgID = missing + refused(t, f, bad, "org_id_fkey", "org_id must reference a real organization") + + // 10. An owner that does not exist. + bad = f.personal(table, "bad-owner", f.alice) + bad.owner = &missing + refused(t, f, bad, "owner_user_id_fkey", "owner_user_id must reference a real user") + + // 11. An author that does not exist. + bad = f.organization(table, "bad-author", f.alice) + bad.createdBy = &missing + refused(t, f, bad, "created_by_fkey", "created_by must reference a real user") + }) + } + + // 12. Deletion, three behaviours, each different and each deliberate. + t.Run("deleting the owner destroys their personal definitions", func(t *testing.T) { + carol := f.newUser(t, "carol@example.test") + mustInsert(t, f, f.personal(agents, "carols-agent", carol)) + mustInsert(t, f, f.personal(skills, "carols-skill", carol)) + + if _, err := f.pool.Exec(f.ctx, `DELETE FROM users WHERE id = $1::uuid`, carol); err != nil { + t.Fatalf("delete the user: %v", err) + } + for _, table := range []string{agents, skills} { + var n int + if err := f.pool.QueryRow(f.ctx, + "SELECT count(*)::int FROM "+table+" WHERE definition_id LIKE 'carols-%'").Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 0 { + t.Errorf("%s: %d personal definitions survive their deleted owner, want 0", table, n) + } + } + }) + + t.Run("deleting the author keeps the organization's definition", func(t *testing.T) { + dave := f.newUser(t, "dave@example.test") + id := mustInsert(t, f, f.organization(agents, "daves-shared-agent", dave)) + + if _, err := f.pool.Exec(f.ctx, `DELETE FROM users WHERE id = $1::uuid`, dave); err != nil { + t.Fatalf("delete the user: %v", err) + } + var author *string + if err := f.pool.QueryRow(f.ctx, + `SELECT created_by::text FROM agent_definitions WHERE id = $1::uuid`, id).Scan(&author); err != nil { + t.Fatalf("the shared definition did not survive its author: %v", err) + } + if author != nil { + t.Errorf("created_by = %v, want NULL after the author was deleted", *author) + } + }) + + t.Run("deleting the organization destroys both tiers", func(t *testing.T) { + g := newFixture(t, "defs_orgcascade") + mustInsert(t, g, g.personal(agents, "doomed-personal", g.alice)) + mustInsert(t, g, g.organization(skills, "doomed-shared", g.alice)) + + if _, err := g.pool.Exec(g.ctx, `DELETE FROM organizations WHERE id = $1::uuid`, g.orgID); err != nil { + t.Fatalf("delete the organization: %v", err) + } + for _, table := range []string{agents, skills} { + var n int + if err := g.pool.QueryRow(g.ctx, "SELECT count(*)::int FROM "+table).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 0 { + t.Errorf("%s: %d rows survive their deleted organization, want 0", table, n) + } + } + }) +} + +/* ── 13, 14. Uniqueness, per tier ───────────────────────────────────────── */ + +func TestUniquenessPerTier(t *testing.T) { + f := newFixture(t, "defs_unique") + + for _, table := range []string{agents, skills} { + t.Run(table, func(t *testing.T) { + // 13. One personal definition per id per owner. + mustInsert(t, f, f.personal(table, "board", f.alice)) + refused(t, f, f.personal(table, "board", f.alice), "personal_key", + "one user cannot hold two personal definitions of the same id") + + // A different user may hold their own, which is the whole point of + // personal definitions. + mustInsert(t, f, f.personal(table, "board", f.bob)) + + // 14. One organization definition per id per organization. + mustInsert(t, f, f.organization(table, "board", f.alice)) + refused(t, f, f.organization(table, "board", f.bob), "org_key", + "one organization cannot hold two shared definitions of the same id") + + // Personal and organization definitions of the SAME id coexist: + // that is shadow-by-id, and it is the reason definition_id is not + // globally unique. + var personal, shared int + if err := f.pool.QueryRow(f.ctx, + "SELECT count(*) FILTER (WHERE visibility='personal'), "+ + "count(*) FILTER (WHERE visibility='organization') "+ + "FROM "+table+" WHERE definition_id = 'board'").Scan(&personal, &shared); err != nil { + t.Fatalf("count: %v", err) + } + if personal != 2 || shared != 1 { + t.Errorf("board: %d personal + %d shared, want 2 + 1", personal, shared) + } + }) + } + + // A second organization may hold its own definition of the same id. + t.Run("across organizations", func(t *testing.T) { + var otherOrg string + if err := f.pool.QueryRow(f.ctx, + `INSERT INTO organizations (name, slug) VALUES ('Other','other-defs') RETURNING id::text`). + Scan(&otherOrg); err != nil { + t.Fatalf("create the second organization: %v", err) + } + var erin string + if err := f.pool.QueryRow(f.ctx, + `INSERT INTO users (org_id, email, full_name) VALUES ($1::uuid,'erin@example.test','Erin') + RETURNING id::text`, otherOrg).Scan(&erin); err != nil { + t.Fatalf("create a user in the second organization: %v", err) + } + r := f.organization(agents, "board", erin) + r.orgID = otherOrg + mustInsert(t, f, r) + }) +} + +/* ── Schema shape ───────────────────────────────────────────────────────── */ + +func TestDefinitionTablesShape(t *testing.T) { + f := newFixture(t, "defs_shape") + + shared := map[string]string{ + "id": "uuid", + "definition_id": "text", + "org_id": "uuid", + "visibility": "text", + "owner_user_id": "uuid", + "created_by": "text-or-uuid", // placeholder, replaced below + "markdown": "text", + "status": "text", + "name": "text", + "description": "text", + "pages": "ARRAY", + "created_date": "timestamp with time zone", + "updated_date": "timestamp with time zone", + } + shared["created_by"] = "uuid" + + nullable := map[string]bool{"owner_user_id": true, "created_by": true} + + for _, table := range []string{agents, skills} { + want := map[string]string{} + for k, v := range shared { + want[k] = v + } + if table == agents { + want["version"] = "integer" + } + + t.Run(table, func(t *testing.T) { + rows, err := f.pool.Query(f.ctx, + `SELECT column_name, data_type, is_nullable + FROM information_schema.columns + WHERE table_schema='public' AND table_name=$1`, table) + if err != nil { + t.Fatalf("read columns: %v", err) + } + got := map[string]string{} + for rows.Next() { + var name, kind, isNullable string + if err := rows.Scan(&name, &kind, &isNullable); err != nil { + t.Fatalf("scan: %v", err) + } + got[name] = kind + if (isNullable == "YES") != nullable[name] { + t.Errorf("%s.%s is_nullable=%s, want nullable=%v", table, name, isNullable, nullable[name]) + } + } + rows.Close() + if err := rows.Err(); err != nil { + t.Fatalf("read columns: %v", err) + } + + for name, kind := range want { + if got[name] == "" { + t.Errorf("%s.%s is missing", table, name) + } else if got[name] != kind { + t.Errorf("%s.%s is %s, want %s", table, name, got[name], kind) + } + } + for name := range got { + if _, expected := want[name]; !expected { + t.Errorf("%s has an unexpected column %q", table, name) + } + } + }) + } +} + +func TestDefinitionIndexes(t *testing.T) { + f := newFixture(t, "defs_indexes") + + want := map[string][]string{ + agents: { + "agent_definitions_pkey", + "agent_definitions_personal_key", + "agent_definitions_org_key", + "agent_definitions_org_visibility_idx", + "agent_definitions_owner_idx", + "agent_definitions_published_idx", + }, + skills: { + "skill_definitions_pkey", + "skill_definitions_personal_key", + "skill_definitions_org_key", + "skill_definitions_org_visibility_idx", + "skill_definitions_owner_idx", + "skill_definitions_active_idx", + }, + } + + for table, names := range want { + rows, err := f.pool.Query(f.ctx, + `SELECT indexname, indexdef FROM pg_indexes WHERE schemaname='public' AND tablename=$1`, table) + if err != nil { + t.Fatalf("list indexes: %v", err) + } + got := map[string]string{} + for rows.Next() { + var name, def string + if err := rows.Scan(&name, &def); err != nil { + t.Fatalf("scan: %v", err) + } + got[name] = def + } + rows.Close() + + for _, name := range names { + if got[name] == "" { + t.Errorf("%s: index %s is missing", table, name) + } + } + // The two uniqueness indexes must be partial and unique, or they mean + // something other than what they are named. + for _, name := range []string{table + "_personal_key", table + "_org_key"} { + def := got[name] + if !strings.Contains(def, "UNIQUE") { + t.Errorf("%s is not UNIQUE: %s", name, def) + } + if !strings.Contains(def, "WHERE") { + t.Errorf("%s is not partial: %s", name, def) + } + } + } + + // created_by is deliberately unindexed: attribution only, no listing is + // keyed by it, and its SET NULL scan happens only when a user is deleted. + for _, table := range []string{agents, skills} { + var n int + if err := f.pool.QueryRow(f.ctx, + `SELECT count(*)::int FROM pg_indexes + WHERE schemaname='public' AND tablename=$1 AND indexdef LIKE '%(created_by)%'`, + table).Scan(&n); err != nil { + t.Fatalf("look for a created_by index: %v", err) + } + if n != 0 { + t.Errorf("%s has an index on created_by; it was deliberately omitted", table) + } + } +} + +/* ── Reversibility ──────────────────────────────────────────────────────── */ + +func TestMigration000005IsReversible(t *testing.T) { + ctx := context.Background() + pool := testutil.Sandbox(t, "defs_reversible") + testutil.ApplyAllMigrations(ctx, t, pool) + + const up = "000005_agent_skill_definitions.up.sql" + const down = "000005_agent_skill_definitions.down.sql" + + exists := func(name string) bool { + var reg *string + if err := pool.QueryRow(ctx, `SELECT to_regclass('public.' || $1)::text`, name).Scan(®); err != nil { + t.Fatalf("to_regclass(%s): %v", name, err) + } + return reg != nil + } + + for _, table := range []string{agents, skills} { + if !exists(table) { + t.Fatalf("%s does not exist before the rollback", table) + } + } + + if err := testutil.ApplyMigration(ctx, t, pool, down); err != nil { + t.Fatalf("apply %s: %v", down, err) + } + for _, table := range []string{agents, skills} { + if exists(table) { + t.Errorf("%s survived the rollback", table) + } + } + + // The rollback must reach nothing that predates it. + for _, table := range []string{"users", "organizations", "sessions", "user_preferences", "job_postings"} { + if !exists(table) { + t.Fatalf("the rollback dropped %s, which 000005 did not create", table) + } + } + // And no enum type was created, so none can be left behind. + var leftover int + if err := pool.QueryRow(ctx, + `SELECT count(*)::int FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname='public' AND t.typtype='e' + AND t.typname IN ('definition_visibility','agent_status','skill_status')`).Scan(&leftover); err != nil { + t.Fatalf("look for leftover types: %v", err) + } + if leftover != 0 { + t.Errorf("%d enum types left behind by the rollback", leftover) + } + + // Re-applying restores exactly what was removed. + if err := testutil.ApplyMigration(ctx, t, pool, up); err != nil { + t.Fatalf("re-apply %s: %v", up, err) + } + for _, table := range []string{agents, skills} { + if !exists(table) { + t.Errorf("%s did not come back", table) + } + } +} + +// Every migration still has a matching down file, and 000005 is the newest. +func TestMigrationPairsIncluding000005(t *testing.T) { + ups := testutil.MigrationFiles(t, ".up.sql") + downs := testutil.MigrationFiles(t, ".down.sql") + if len(ups) != len(downs) { + t.Fatalf("%d up and %d down migrations", len(ups), len(downs)) + } + for i, up := range ups { + want := strings.TrimSuffix(up, ".up.sql") + ".down.sql" + if downs[i] != want { + t.Errorf("%s has no matching down migration (found %s)", up, downs[i]) + } + } + if len(ups) != 5 { + t.Errorf("%d migrations, want 5", len(ups)) + } + if ups[4] != "000005_agent_skill_definitions.up.sql" { + t.Errorf("the last migration is %s", ups[4]) + } +} + +// 000005 creates exactly two tables and nothing else. The Phase 4B decision was +// explicit about which tables must NOT appear; this is that decision, asserted. +func TestMigrationAddsExactlyTwoTables(t *testing.T) { + f := newFixture(t, "defs_tablecount") + + var n int + if err := f.pool.QueryRow(f.ctx, + `SELECT count(*)::int FROM information_schema.tables + WHERE table_schema='public' AND table_type='BASE TABLE'`).Scan(&n); err != nil { + t.Fatalf("count tables: %v", err) + } + // 17 from 000001 + sessions from 000004 + the two here. schema_migrations is + // golang-migrate's and is absent when the files are applied directly. + if n != 20 { + t.Errorf("%d base tables after every migration, want 20", n) + } + + for _, forbidden := range []string{ + "definition_versions", "definition_permissions", "agent_skills", + "agent_subagents", "agent_knowledge", "conversations", + "conversation_messages", "conversation_feedback", + } { + var reg *string + if err := f.pool.QueryRow(f.ctx, + `SELECT to_regclass('public.' || $1)::text`, forbidden).Scan(®); err != nil { + t.Fatalf("to_regclass: %v", err) + } + if reg != nil { + t.Errorf("table %s exists; Phase 4B deferred or rejected it", forbidden) + } + } +} diff --git a/go-api/internal/domain/errors.go b/go-api/internal/domain/errors.go new file mode 100644 index 0000000..66176a1 --- /dev/null +++ b/go-api/internal/domain/errors.go @@ -0,0 +1,76 @@ +package domain + +import "fmt" + +// Error is an API-level failure carrying the contract's error code. +// See api-contract.md §5. +type Error struct { + Code string + Message string + Details map[string]string + cause error +} + +func (e *Error) Error() string { return e.Message } +func (e *Error) Unwrap() error { return e.cause } + +// NotFound reproduces store.js's thrown message verbatim: " not +// found", using the frontend's entity name rather than the table name. +func NotFound(entity, id string) *Error { + return &Error{Code: "not_found", Message: fmt.Sprintf("%s %s not found", entity, id)} +} + +func Invalid(msg string) *Error { + return &Error{Code: "invalid_query", Message: msg} +} + +func Validation(msg string, details map[string]string) *Error { + if details == nil { + details = map[string]string{} + } + return &Error{Code: "validation_failed", Message: msg, Details: details} +} + +func Conflict(msg string) *Error { + return &Error{Code: "conflict", Message: msg} +} + +// Unauthenticated is every "you are not signed in" answer: no cookie, an +// unknown token, an expired session, a suspended user, a wrong password, an +// email that does not exist. +// +// One constructor for all of them, deliberately. The distinctions matter in the +// server log and must not reach the client: which of those it was tells an +// attacker whether an address is registered, whether an account is suspended, +// or whether a guessed token was ever real. +func Unauthenticated() *Error { + return &Error{Code: "unauthorized", Message: "authentication required"} +} + +// Forbidden is the answer to an authenticated caller whose role does not permit +// the operation. +// +// Distinct from Unauthenticated: 401 means "I do not know who you are", 403 +// means "I know exactly who you are and the answer is still no". Conflating +// them makes a client retry a login that will not help. +// +// The message names neither the role the caller has nor the roles that would +// have worked. That is not secrecy for its own sake — it is that an endpoint +// which answers "employers only" to a talent user is an endpoint that maps the +// organization's privilege structure for anyone who asks. +// +// Note what does NOT come through here: a row belonging to another +// organization, or to another person, is not forbidden — it is absent. Those +// answer 404 by way of a SQL predicate, so existence never leaks. +func Forbidden() *Error { + return &Error{Code: "forbidden", Message: "you do not have access to this operation"} +} + +// RateLimited is the answer to too many failed sign-in attempts. +func RateLimited(msg string) *Error { + return &Error{Code: "rate_limited", Message: msg} +} + +func Internal(err error) *Error { + return &Error{Code: "internal", Message: "internal error", cause: err} +} diff --git a/go-api/internal/domain/policy.go b/go-api/internal/domain/policy.go new file mode 100644 index 0000000..5641171 --- /dev/null +++ b/go-api/internal/domain/policy.go @@ -0,0 +1,330 @@ +package domain + +// Authorization policy: who may perform which operation on which resource, and +// which rows they may see when they get there. +// +// This file is hand-written and `resources_gen.go` is generated, which is the +// whole reason they are separate. Regenerating the descriptors from the live +// schema must never silently drop an access rule, and a column appearing in the +// database must never grant anybody anything by accident. +// +// Three properties hold here by construction: +// +// - DENY BY DEFAULT. A resource with no policy permits nothing, to anyone. A +// resource added to the schema tomorrow is unreachable until somebody +// writes down who may reach it. TestEveryResourceHasAPolicy makes the +// omission loud rather than silent. +// - ROLE IS users.role, ALWAYS. Never account_type — which the user can +// change on themselves through PATCH /me — and never anything read from a +// request body, a header or the browser. +// - OWNERSHIP IS A SQL PREDICATE, NOT A FILTER. TalentScope describes a WHERE +// clause the repository adds beside the organization scope. Rows a talent +// user may not see are never fetched, so they cannot leak through a count, +// a total or a bug in a later loop. +// +// Authorization is checked in the handler, before any query runs, and answers +// 403. Organization and ownership are predicates, so a row outside them is +// simply absent and answers 404 — the caller cannot tell "exists but not yours" +// from "does not exist", which is the point. + +// Role is the authorization authority. It mirrors the users_role_check +// constraint in migration 000001 and there are deliberately no others. +type Role string + +const ( + RoleAdmin Role = "admin" + RoleEmployer Role = "employer" + RoleTalent Role = "talent" +) + +// ParseRole converts a stored users.role into a Role, reporting whether it is +// one this API recognises. An unrecognised value authorizes nothing. +func ParseRole(s string) (Role, bool) { + switch Role(s) { + case RoleAdmin: + return RoleAdmin, true + case RoleEmployer: + return RoleEmployer, true + case RoleTalent: + return RoleTalent, true + } + return "", false +} + +/* ── Row visibility ─────────────────────────────────────────────────────── */ + +// ScopeKind is how a resource decides which rows a talent user may see. +type ScopeKind int + +const ( + // ScopeNone: no extra predicate. Every row in the organization is visible. + ScopeNone ScopeKind = iota + // ScopeUserID: Column = the authenticated user's id. + ScopeUserID + // ScopeEmail: Column = the authenticated user's email. + // + // Used where the schema ties a row to a person by email string rather than + // by a foreign key — assignments, evidence, shift records, applications, + // activity. Those columns have no FK (see the Phase 3D audit, F-05), so the + // write path is what makes this trustworthy: a talent caller never supplies + // the value, it is derived from the session. See Derived. + ScopeEmail + // ScopeOwnApplications: the row references a job application belonging to + // the authenticated user. Ownership by reference rather than by column — + // an AI interview names an application, and the application names a person. + ScopeOwnApplications + // ScopeActivePostings: visibility rather than ownership. A talent user sees + // the postings they could apply to, not the organization's drafts, paused + // roles or closed history. + ScopeActivePostings +) + +// Scope is the predicate applied to a talent caller's rows. +type Scope struct { + Kind ScopeKind + // Column is the column carrying the owner, for ScopeUserID and ScopeEmail. + // For ScopeOwnApplications it is the column referencing the application. + // For ScopeActivePostings it is the status column. + // + // Required by every kind except ScopeNone: a scope naming a column the + // resource does not have matches no rows at all, which is the safe + // direction to fail but is still a bug worth noticing. + Column string +} + +/* ── Server-owned values ────────────────────────────────────────────────── */ + +// DeriveSource names which fact about the caller fills a column. +type DeriveSource int + +const ( + DeriveUserID DeriveSource = iota + DeriveEmail + DeriveFullName + DeriveAccountType +) + +// Derived is a column the server fills in on insert from the session. +// +// Every column named here is also ReadOnly in the descriptors, so a value in a +// request body is dropped before it reaches SQL. This is the other half: the +// column still has to be filled, and the only acceptable source is the +// authenticated identity. +type Derived struct { + Column string + Source DeriveSource + // TalentOnly restricts the derivation to talent callers. + // + // It exists because two different questions wear the same shape. `created_by` + // and the user_activity columns record WHO ACTED, so they are the session + // user whoever that is. `worker_profiles.user_id`, `job_applications.email` + // and `evidence.worker_email` record WHO THE ROW IS ABOUT — and when an + // admin creates a candidate's profile or logs an application on their + // behalf, the subject is emphatically not the admin. Deriving those + // unconditionally would quietly file every candidate's record under the + // operator who typed it in. + TalentOnly bool +} + +/* ── Policy ─────────────────────────────────────────────────────────────── */ + +// Policy is one resource's access rules. +// +// A nil Policy denies everything. An empty role list for an operation denies +// that operation to everyone, which is how an operation the resource does not +// support is expressed. +type Policy struct { + List []Role + Get []Role + Create []Role + Update []Role + Delete []Role + + // TalentScope narrows which rows a talent caller may read or write. It is + // applied to talent and to nobody else: admin and employer see the whole + // organization, which is what an operator console is for. + TalentScope Scope + + // Derived fills server-owned columns on insert. + Derived []Derived +} + +// Allows reports whether a role may perform an operation. +// +// The zero answer is no: a nil policy, an unknown operation and an unlisted +// role all deny. +func (p *Policy) Allows(op Op, role Role) bool { + if p == nil { + return false + } + for _, r := range p.rolesFor(op) { + if r == role { + return true + } + } + return false +} + +func (p *Policy) rolesFor(op Op) []Role { + switch op { + case OpList: + return p.List + case OpGet: + return p.Get + case OpCreate: + return p.Create + case OpUpdate: + return p.Update + case OpDelete: + return p.Delete + } + return nil +} + +// ScopeFor returns the row predicate that applies to a role. Only talent is +// scoped; every other role sees the organization. +func (p *Policy) ScopeFor(role Role) Scope { + if p == nil || role != RoleTalent { + return Scope{} + } + return p.TalentScope +} + +/* ── The table ──────────────────────────────────────────────────────────── */ + +var ( + everyone = []Role{RoleAdmin, RoleEmployer, RoleTalent} + // operators are the roles that run the organization's hiring and workforce: + // they see and act on the whole tenant. Talent is not one of them. + operators = []Role{RoleAdmin, RoleEmployer} + adminOnly = []Role{RoleAdmin} +) + +// policies is the authorization contract, keyed by URL path. +// +// Read this table as the answer to "who may call this, and which rows do they +// get". It is the only place those two questions are answered. +var policies = map[string]*Policy{ + + // Postings are the organization's shop window. Operators author them; + // talent sees the ones that are open, and nothing else — not the drafts, + // not the paused roles, not the closed history. + "job-postings": { + List: everyone, Get: everyone, + Create: operators, Update: operators, + TalentScope: Scope{Kind: ScopeActivePostings, Column: "status"}, + Derived: []Derived{{Column: "created_by", Source: DeriveUserID}}, + }, + + // An application is written by a person about themselves. Talent may file + // one and read their own; only operators may move it through the funnel or + // remove it. A talent caller never supplies the email — it is the session's, + // which is what makes the read predicate below mean anything. + "job-applications": { + List: everyone, Create: everyone, + Update: operators, Delete: operators, + TalentScope: Scope{Kind: ScopeEmail, Column: "email"}, + Derived: []Derived{{Column: "email", Source: DeriveEmail, TalentOnly: true}}, + }, + + // An interview belongs to an application, and the application belongs to a + // person. Talent reads their own and may sit one for an application of + // theirs; the insert guard in the repository enforces the second half. + "ai-interviews": { + List: everyone, Create: everyone, + TalentScope: Scope{Kind: ScopeOwnApplications, Column: "application_id"}, + }, + + // The employment record of the organization's workforce. Operators only: + // it carries endorsements, review dates and reviewer names, which are the + // organization's assessment of a person rather than the person's own data. + "staff": { + List: operators, Create: operators, Update: operators, + }, + + // A worker's own profile: contact details, address, salary expectations, + // personality assessment. Talent may read and maintain theirs and no other. + // user_id is server-owned, so a talent caller cannot claim someone else's + // profile by naming them, and cannot hand theirs away. + "worker-profiles": { + List: everyone, Create: everyone, Update: everyone, + TalentScope: Scope{Kind: ScopeUserID, Column: "user_id"}, + Derived: []Derived{{Column: "user_id", Source: DeriveUserID, TalentOnly: true}}, + }, + + // Who is on which position. Operators allocate; talent reads their own + // roster and cannot create one — being assigned to work is not a thing you + // do to yourself. + "assignments": { + List: everyone, Create: operators, + TalentScope: Scope{Kind: ScopeEmail, Column: "worker_email"}, + }, + + // Attendance. Read-only for everyone over the API — the seeder owns these + // rows — and talent sees only their own shifts. + "shift-records": { + List: everyone, + TalentScope: Scope{Kind: ScopeEmail, Column: "worker_email"}, + }, + + // The training library. Everyone learns from it; only admin authors it. + // Employer is excluded from authoring deliberately: courses with a NULL + // org_id are the shared platform library, visible to every tenant, so a + // write here can reach beyond the writer's own organization. + "courses": { + List: everyone, Get: everyone, + Create: adminOnly, Update: adminOnly, + }, + + "learning-paths": {List: everyone}, + + // Organization taxonomy: the role names positions are filed under. + "role-categories": { + List: everyone, Create: operators, + }, + + // Organization taxonomy: the certifications positions may require. + // Deleting one changes what every existing posting means, so it is admin's. + "certifications": { + List: everyone, Create: operators, Delete: adminOnly, + }, + + // The audit log. Append-only by schema (no update, no delete). Anyone may + // write an entry about themselves — and only about themselves: all four + // identity columns are server-derived, so an entry cannot be attributed to + // someone else. Operators read the organization's log; talent reads theirs. + "user-activity": { + List: everyone, Create: everyone, + TalentScope: Scope{Kind: ScopeEmail, Column: "user_email"}, + Derived: []Derived{ + {Column: "user_id", Source: DeriveUserID}, + {Column: "user_email", Source: DeriveEmail}, + {Column: "user_name", Source: DeriveFullName}, + {Column: "account_type", Source: DeriveAccountType}, + }, + }, + + // Proof of work: a worker submits it, the organization verifies it. + // Talent may submit their own and read it back; the verdict is an operator + // judgement, so talent cannot PATCH. + "evidence": { + List: everyone, Create: everyone, Update: operators, + TalentScope: Scope{Kind: ScopeEmail, Column: "worker_email"}, + Derived: []Derived{{Column: "worker_email", Source: DeriveEmail, TalentOnly: true}}, + }, + + // Badges have no endpoints at all (Ops: 0 — the frontend's Badge.list call + // has 404ed since Phase 2C). The empty policy is written out rather than + // omitted so that the resource is deliberately closed rather than merely + // forgotten, and so TestEveryResourceHasAPolicy passes honestly. + "badges": {}, +} + +// init attaches the policies to the descriptors. +// +// A resource with no entry keeps a nil Policy and therefore permits nothing. +func init() { + for _, r := range AllResources { + r.Policy = policies[r.Path] + } +} diff --git a/go-api/internal/domain/policy_test.go b/go-api/internal/domain/policy_test.go new file mode 100644 index 0000000..d878c5a --- /dev/null +++ b/go-api/internal/domain/policy_test.go @@ -0,0 +1,191 @@ +package domain + +import "testing" + +// Invariants of the policy table itself. No database: these catch the mistakes +// that would otherwise only show up as a missing 403 in an integration test, or +// not at all. + +// Every resource must say who may reach it. A resource added to the schema and +// left out of policies.go is unreachable — which is the safe direction, and +// still a mistake worth failing on rather than discovering in production. +func TestEveryResourceHasAPolicy(t *testing.T) { + for _, r := range AllResources { + if r.Policy == nil { + t.Errorf("resource %q (%s) has no policy: it permits nothing, which is safe but almost certainly unintended", + r.Name, r.Path) + } + } +} + +// A nil policy denies everything. This is the property the test above relies on +// being true, so it is asserted rather than assumed. +func TestNilPolicyDeniesEverything(t *testing.T) { + var p *Policy + for _, op := range []Op{OpList, OpGet, OpCreate, OpUpdate, OpDelete} { + for _, role := range []Role{RoleAdmin, RoleEmployer, RoleTalent} { + if p.Allows(op, role) { + t.Errorf("a nil policy allowed op %d for %s", op, role) + } + } + } + if got := p.ScopeFor(RoleTalent); got.Kind != ScopeNone { + t.Error("a nil policy returned a scope") + } +} + +// A policy must not grant an operation the resource does not expose. Such a +// grant is dead — no route is registered — but it reads as permission and would +// become real the moment the operation is added. +func TestPolicyGrantsNothingWithoutARoute(t *testing.T) { + ops := []struct { + op Op + name string + }{ + {OpList, "List"}, {OpGet, "Get"}, {OpCreate, "Create"}, + {OpUpdate, "Update"}, {OpDelete, "Delete"}, + } + for _, r := range AllResources { + if r.Policy == nil { + continue + } + for _, o := range ops { + granted := len(r.Policy.rolesFor(o.op)) > 0 + if granted && !r.Supports(o.op) { + t.Errorf("%s: policy grants %s but the resource has no such route", r.Path, o.name) + } + } + } +} + +// An unrecognised role authorizes nothing, whatever the policy says. +func TestUnknownRoleIsDenied(t *testing.T) { + if _, ok := ParseRole("superuser"); ok { + t.Fatal("ParseRole accepted a role outside the users_role_check constraint") + } + if _, ok := ParseRole(""); ok { + t.Fatal("ParseRole accepted an empty role") + } + for _, r := range AllResources { + if r.Policy.Allows(OpList, Role("superuser")) { + t.Errorf("%s allows an unknown role", r.Path) + } + } + // The three real ones parse. + for _, want := range []Role{RoleAdmin, RoleEmployer, RoleTalent} { + if got, ok := ParseRole(string(want)); !ok || got != want { + t.Errorf("ParseRole(%q) = %q, %v", want, got, ok) + } + } +} + +// Every column the server derives must also be ReadOnly, or a request body +// could still set it on a path the derivation does not cover. +func TestDerivedColumnsAreReadOnlyOrTalentScoped(t *testing.T) { + for _, r := range AllResources { + if r.Policy == nil { + continue + } + for _, d := range r.Policy.Derived { + col, ok := r.Column(d.Column) + if !ok { + t.Errorf("%s: derives %q, which is not a column", r.Path, d.Column) + continue + } + // A TalentOnly derivation intentionally leaves the column writable + // for operators — an admin filing a candidate's application must be + // able to say whose it is. The unconditional ones must be sealed. + if !d.TalentOnly && !col.ReadOnly { + t.Errorf("%s.%s is derived unconditionally but is not ReadOnly: a request body could still set it", + r.Path, d.Column) + } + } + } +} + +// The six columns Phase 3D closed. Named explicitly, so that regenerating the +// descriptors without the SERVER_OWNED map in gen_resources.py fails loudly +// rather than silently reopening the holes. +func TestServerOwnedColumnsAreReadOnly(t *testing.T) { + sealed := map[string][]string{ + "worker-profiles": {"user_id"}, + "user-activity": {"user_id", "user_email", "user_name", "account_type"}, + "job-postings": {"created_by"}, + } + for path, cols := range sealed { + res, ok := ResourceByPath[path] + if !ok { + t.Fatalf("resource %s is missing", path) + } + for _, name := range cols { + col, ok := res.Column(name) + if !ok { + t.Errorf("%s has no column %s", path, name) + continue + } + if !col.ReadOnly { + t.Errorf("%s.%s is not ReadOnly — a client could supply it", path, name) + } + } + } + + // And org_id everywhere, which predates Phase 3D and must stay that way. + for _, r := range AllResources { + if col, ok := r.Column("org_id"); ok && !col.ReadOnly { + t.Errorf("%s.org_id is not ReadOnly", r.Path) + } + } +} + +// Talent is the only scoped role. If a scope ever applied to an operator the +// admin console would start losing rows, which is a failure mode worth pinning. +func TestOnlyTalentIsRowScoped(t *testing.T) { + for _, r := range AllResources { + for _, role := range []Role{RoleAdmin, RoleEmployer} { + if got := r.Policy.ScopeFor(role); got.Kind != ScopeNone { + t.Errorf("%s scopes rows for %s: operators see the whole organization", r.Path, role) + } + } + } +} + +// Every talent scope must name a column the resource actually has. +func TestTalentScopesNameRealColumns(t *testing.T) { + for _, r := range AllResources { + scope := r.Policy.ScopeFor(RoleTalent) + if scope.Kind == ScopeNone { + continue + } + if scope.Column == "" { + t.Errorf("%s has a talent scope with no column", r.Path) + continue + } + if _, ok := r.Column(scope.Column); !ok { + t.Errorf("%s scopes on %q, which is not one of its columns", r.Path, scope.Column) + } + } +} + +// Talent must not reach an operator resource by having a scope but no grant, +// or a grant but no scope where one is required. This pins the shape of the +// contract: wherever talent may list a resource that also holds other people's +// rows, a scope must narrow it. +func TestTalentGrantsHaveScopesWhereRowsAreShared(t *testing.T) { + // Resources whose rows are the organization's rather than any one person's: + // a talent grant here is deliberate and needs no ownership predicate. + shared := map[string]bool{ + "courses": true, "learning-paths": true, + "role-categories": true, "certifications": true, + } + for _, r := range AllResources { + if !r.Policy.Allows(OpList, RoleTalent) { + continue + } + if shared[r.Path] { + continue + } + if r.Policy.ScopeFor(RoleTalent).Kind == ScopeNone { + t.Errorf("%s: talent may list it but no ownership scope narrows the rows", r.Path) + } + } +} diff --git a/go-api/internal/domain/record.go b/go-api/internal/domain/record.go new file mode 100644 index 0000000..576b098 --- /dev/null +++ b/go-api/internal/domain/record.go @@ -0,0 +1,35 @@ +package domain + +// Record is one row as the API exposes it: the frontend's exact field names +// mapped to JSON-ready values. +// +// A map rather than a struct per resource. The frontend treats every record as +// an opaque bag of fields it round-trips unchanged — `store.js` stores whatever +// it was handed and returns a clone — and fourteen structs totalling ~350 +// fields would add a transcription risk without adding a guarantee. Typing +// lives in the Column descriptors instead, where validation and SQL both read +// it from one place. +type Record map[string]any + +// ListParams is a parsed, validated collection query. +type ListParams struct { + Sort string // column name, without the leading '-' + Desc bool + Limit int + Offset int + Filters []Filter +} + +// Filter is one equality or membership test. See api-contract.md §6. +type Filter struct { + Column *Column + Values []string // len > 1 means IN +} + +// Page is a collection result plus the metadata the envelope reports. +type Page struct { + Records []Record + Total int + Limit int + Offset int +} diff --git a/go-api/internal/domain/resource.go b/go-api/internal/domain/resource.go new file mode 100644 index 0000000..be03076 --- /dev/null +++ b/go-api/internal/domain/resource.go @@ -0,0 +1,164 @@ +// Package domain describes the API's resources: their columns, types and the +// operations each one supports. +// +// The descriptors here are the single place the contract in +// `docs/api-contract.md` is encoded. The repository, service and HTTP layers +// are all driven from them, so a semantic is implemented once and applies +// identically to every resource — which is the point. Fourteen hand-written +// repositories would be fourteen chances to get NULLS LAST wrong. +package domain + +import "fmt" + +// Kind is a column's value type, as the API presents it. +type Kind int + +const ( + KindString Kind = iota + KindInt + KindFloat + KindBool + KindTimestamp + KindDate + KindTextArray + KindJSON + KindUUID + KindEnum +) + +// Op is a supported operation, as a bit set. +type Op uint8 + +const ( + OpList Op = 1 << iota + OpGet + OpCreate + OpUpdate + OpDelete +) + +// Column is one database column and how the API treats it. +type Column struct { + Name string + Kind Kind + PGType string // the type every parameter is explicitly cast to + NotNull bool // database-level NOT NULL + ReadOnly bool // server-owned: ignored if present in a request body + Required bool // must be supplied, non-blank, on create + Enum []string // permitted values when Kind == KindEnum +} + +// Resource is one API resource and its backing table. +type Resource struct { + Name string // frontend entity name, used verbatim in error messages + Path string // URL segment + Table string + Columns []Column + DefaultSort string + DefaultLimit int + Ops Op + // OrgNullable marks a table where a NULL org_id means "shared across every + // organization" — the platform course library. Reads match org OR NULL. + OrgNullable bool + + // Policy is who may do what, and which rows they see. Attached from + // policy.go, which is hand-written; nil means the resource permits nothing. + Policy *Policy + + byName map[string]*Column +} + +// Supports reports whether the resource exposes an operation. +func (r *Resource) Supports(op Op) bool { return r.Ops&op != 0 } + +// Column looks a column up by name. +func (r *Resource) Column(name string) (*Column, bool) { + if r.byName == nil { + r.byName = make(map[string]*Column, len(r.Columns)) + for i := range r.Columns { + r.byName[r.Columns[i].Name] = &r.Columns[i] + } + } + c, ok := r.byName[name] + return c, ok +} + +// Filterable reports whether a column may appear as a query filter. +// +// Arrays and JSON are excluded deliberately. `store.js` compares with `===`, +// so a filter against an array column matches nothing today; supporting +// containment here would be a silent behaviour change, not a fix. +// See api-contract.md §6. +func (r *Resource) Filterable(name string) bool { + c, ok := r.Column(name) + if !ok { + return false + } + switch c.Kind { + case KindTextArray, KindJSON: + return false + } + return true +} + +// Sortable reports whether a column may be sorted on. Any real column may be. +func (r *Resource) Sortable(name string) bool { + _, ok := r.Column(name) + return ok +} + +// SelectExpr is the SQL that reads one column back in its API representation. +// +// The casts are not cosmetic. pgx hands back a [16]byte for uuid and a +// pgtype.Numeric for numeric, neither of which JSON-encodes as the frontend +// expects, and both are cheaper to fix in the projection than in Go. +func (c Column) SelectExpr() string { + switch c.Kind { + case KindUUID: + return fmt.Sprintf("%s::text AS %s", c.Name, c.Name) + case KindFloat: + return fmt.Sprintf("%s::float8 AS %s", c.Name, c.Name) + case KindDate: + return fmt.Sprintf("to_char(%s, 'YYYY-MM-DD') AS %s", c.Name, c.Name) + case KindTimestamp: + // Reproduces the millisecond ISO-8601 form the seed data uses, so a + // record read back over HTTP is byte-identical to what the frontend + // has always seen from localStorage. + return fmt.Sprintf( + `to_char(%s AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS %s`, c.Name, c.Name) + case KindString: + if c.PGType == "citext" { + // pgx has no codec registered for citext, so without this the value + // comes back as an unmapped type rather than a string. + return fmt.Sprintf("%s::text AS %s", c.Name, c.Name) + } + return c.Name + case KindInt: + if c.PGType == "bigint" { + // user_activity.id is an identity bigint. Every id the frontend + // handles is an opaque string, so it stays one here too. + return fmt.Sprintf("%s::text AS %s", c.Name, c.Name) + } + return c.Name + default: + return c.Name + } +} + +// ResourceByPath indexes AllResources by URL segment. +var ResourceByPath = func() map[string]*Resource { + m := make(map[string]*Resource, len(AllResources)) + for _, r := range AllResources { + m[r.Path] = r + } + return m +}() + +// ResourceByTable indexes AllResources by table name. +var ResourceByTable = func() map[string]*Resource { + m := make(map[string]*Resource, len(AllResources)) + for _, r := range AllResources { + m[r.Table] = r + } + return m +}() diff --git a/go-api/internal/domain/resources_gen.go b/go-api/internal/domain/resources_gen.go new file mode 100644 index 0000000..dff38ef --- /dev/null +++ b/go-api/internal/domain/resources_gen.go @@ -0,0 +1,396 @@ +// Code generated by scripts/gen_resources.py. DO NOT EDIT BY HAND. +// Regenerate with: make gen-resources +// +// Column names, types, enum values and nullability are read out of +// information_schema so they cannot drift from the migrations. The +// per-resource metadata (path, default sort, default limit, supported +// operations, required fields) comes from docs/api-contract.md. + +package domain + +// AllResources is every resource the API serves. +var AllResources = []*Resource{ + { + Name: "JobPosting", Path: "job-postings", Table: "job_postings", + DefaultSort: "-created_date", DefaultLimit: 100, + Ops: OpList | OpGet | OpCreate | OpUpdate, + Columns: []Column{ + {Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true}, + {Name: "org_id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "created_by", Kind: KindUUID, PGType: "uuid", ReadOnly: true}, + {Name: "company", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "title", Kind: KindString, PGType: "text", NotNull: true, Required: true}, + {Name: "role_category", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "description", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "responsibilities", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + {Name: "qualifications", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + {Name: "nice_to_haves", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + {Name: "custom_requirements", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "physical_requirements", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "leadership_expectations", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "attendance_expectations", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "min_experience_years", Kind: KindInt, PGType: "int", NotNull: true}, + {Name: "english_required", Kind: KindEnum, PGType: "english_level", NotNull: true, Enum: []string{"basic", "conversational", "fluent", "native"}}, + {Name: "certifications_required", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + {Name: "skill_requirements", Kind: KindJSON, PGType: "jsonb", NotNull: true}, + {Name: "pay_range_min", Kind: KindInt, PGType: "int", NotNull: true}, + {Name: "pay_range_max", Kind: KindInt, PGType: "int", NotNull: true}, + {Name: "location", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "status", Kind: KindEnum, PGType: "posting_status", NotNull: true, Enum: []string{"draft", "active", "paused", "closed"}}, + {Name: "ai_generated", Kind: KindBool, PGType: "boolean", NotNull: true}, + {Name: "headcount", Kind: KindInt, PGType: "int", NotNull: true}, + {Name: "start_date", Kind: KindDate, PGType: "date"}, + {Name: "duration_months", Kind: KindFloat, PGType: "numeric"}, + {Name: "priority", Kind: KindEnum, PGType: "posting_priority", NotNull: true, Enum: []string{"urgent", "high", "normal"}}, + {Name: "vetting_criteria", Kind: KindJSON, PGType: "jsonb", NotNull: true}, + {Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + {Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + }, + }, + { + Name: "JobApplication", Path: "job-applications", Table: "job_applications", + DefaultSort: "-ai_score", DefaultLimit: 200, + Ops: OpList | OpCreate | OpUpdate | OpDelete, + Columns: []Column{ + {Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true}, + {Name: "org_id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "job_posting_id", Kind: KindUUID, PGType: "uuid", NotNull: true, Required: true}, + {Name: "worker_profile_id", Kind: KindUUID, PGType: "uuid"}, + {Name: "job_title", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "applicant_name", Kind: KindString, PGType: "text", NotNull: true, Required: true}, + {Name: "email", Kind: KindString, PGType: "citext", NotNull: true, Required: true}, + {Name: "phone", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "years_experience", Kind: KindInt, PGType: "int", NotNull: true}, + {Name: "english_level", Kind: KindEnum, PGType: "english_level", NotNull: true, Enum: []string{"basic", "conversational", "fluent", "native"}}, + {Name: "certifications", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + {Name: "availability", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + {Name: "skills", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + {Name: "companies_worked", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + {Name: "client_rating", Kind: KindFloat, PGType: "numeric", NotNull: true}, + {Name: "professional_summary", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "cover_letter", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "selfie_url", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "status", Kind: KindEnum, PGType: "application_status", NotNull: true, Enum: []string{"applied", "ai_screened", "shortlisted", "interview", "hired", "rejected", "assigned"}}, + {Name: "ai_score", Kind: KindInt, PGType: "int", NotNull: true}, + {Name: "ai_match_label", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "ai_summary", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "ai_strengths", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + {Name: "ai_gaps", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + {Name: "ai_recommendation", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "score_breakdown", Kind: KindJSON, PGType: "jsonb", NotNull: true}, + {Name: "screened_at", Kind: KindTimestamp, PGType: "timestamptz"}, + {Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + {Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + {Name: "interview_id", Kind: KindUUID, PGType: "uuid"}, + }, + }, + { + Name: "AIInterview", Path: "ai-interviews", Table: "ai_interviews", + DefaultSort: "-created_date", DefaultLimit: 100, + Ops: OpList | OpCreate, + Columns: []Column{ + {Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true}, + {Name: "org_id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "application_id", Kind: KindUUID, PGType: "uuid", NotNull: true, Required: true}, + {Name: "job_posting_id", Kind: KindUUID, PGType: "uuid", NotNull: true, Required: true}, + {Name: "job_title", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "candidate_name", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "messages", Kind: KindJSON, PGType: "jsonb", NotNull: true}, + {Name: "overall_interview_score", Kind: KindInt, PGType: "int", NotNull: true}, + {Name: "verdict", Kind: KindEnum, PGType: "interview_verdict", NotNull: true, Enum: []string{"hire", "maybe", "no"}}, + {Name: "hire_recommendation", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "integrity_score", Kind: KindInt, PGType: "int", NotNull: true}, + {Name: "ai_flags", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + {Name: "category_scores", Kind: KindJSON, PGType: "jsonb", NotNull: true}, + {Name: "strengths", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + {Name: "concerns", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + {Name: "best_fit_roles", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + {Name: "summary", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "reasoning", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + {Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + }, + }, + { + Name: "Staff", Path: "staff", Table: "staff", + DefaultSort: "-created_date", DefaultLimit: 100, + Ops: OpList | OpCreate | OpUpdate, + Columns: []Column{ + {Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true}, + {Name: "org_id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "application_id", Kind: KindUUID, PGType: "uuid"}, + {Name: "job_posting_id", Kind: KindUUID, PGType: "uuid"}, + {Name: "worker_profile_id", Kind: KindUUID, PGType: "uuid"}, + {Name: "name", Kind: KindString, PGType: "text", NotNull: true, Required: true}, + {Name: "email", Kind: KindString, PGType: "citext", NotNull: true, Required: true}, + {Name: "phone", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "role", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "profile_tier", Kind: KindEnum, PGType: "profile_tier", NotNull: true, Enum: []string{"Beginner", "Cross-Trained", "Skilled"}}, + {Name: "hire_date", Kind: KindDate, PGType: "date", NotNull: true, Required: true}, + {Name: "ai_score", Kind: KindInt, PGType: "int", NotNull: true}, + {Name: "status", Kind: KindEnum, PGType: "staff_status", NotNull: true, Enum: []string{"onboarding", "active", "inactive"}}, + {Name: "client_rating", Kind: KindFloat, PGType: "numeric", NotNull: true}, + {Name: "endorsement_text", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "endorsed_skills", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + {Name: "review_date", Kind: KindDate, PGType: "date"}, + {Name: "reviewer_name", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + {Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + }, + }, + { + Name: "WorkerProfile", Path: "worker-profiles", Table: "worker_profiles", + DefaultSort: "-krow_score", DefaultLimit: 500, + Ops: OpList | OpCreate | OpUpdate, + Columns: []Column{ + {Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true}, + {Name: "org_id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "user_id", Kind: KindUUID, PGType: "uuid", ReadOnly: true}, + {Name: "full_name", Kind: KindString, PGType: "text", NotNull: true, Required: true}, + {Name: "email", Kind: KindString, PGType: "citext", NotNull: true, Required: true}, + {Name: "phone", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "address", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "selfie_url", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "languages", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + {Name: "availability", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + {Name: "transportation", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "certifications", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + {Name: "experience", Kind: KindJSON, PGType: "jsonb", NotNull: true}, + {Name: "experience_years", Kind: KindInt, PGType: "int", NotNull: true}, + {Name: "current_position", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "desired_position", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "career_goals", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "skills", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + {Name: "industries", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + {Name: "personality", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "strengths", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + {Name: "weaknesses", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + {Name: "communication_style", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "salary_expectations", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "leadership_potential", Kind: KindInt, PGType: "int", NotNull: true}, + {Name: "ai_interview_score", Kind: KindInt, PGType: "int", NotNull: true}, + {Name: "krow_score", Kind: KindInt, PGType: "int", NotNull: true}, + {Name: "reliability_score", Kind: KindInt, PGType: "int", NotNull: true}, + {Name: "profile_completion", Kind: KindInt, PGType: "int", NotNull: true}, + {Name: "xp", Kind: KindInt, PGType: "int", NotNull: true}, + {Name: "completed_courses", Kind: KindJSON, PGType: "jsonb", NotNull: true}, + {Name: "earned_badges", Kind: KindJSON, PGType: "jsonb", NotNull: true}, + {Name: "capabilities", Kind: KindJSON, PGType: "jsonb", NotNull: true}, + {Name: "shifts_completed", Kind: KindInt, PGType: "int", NotNull: true}, + {Name: "attendance_score", Kind: KindInt, PGType: "int", NotNull: true}, + {Name: "performance_score", Kind: KindInt, PGType: "int", NotNull: true}, + {Name: "client_rating", Kind: KindFloat, PGType: "numeric", NotNull: true}, + {Name: "supervisor_rating", Kind: KindFloat, PGType: "numeric", NotNull: true}, + {Name: "status", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + {Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + {Name: "score_breakdown", Kind: KindJSON, PGType: "jsonb", NotNull: true}, + }, + }, + { + Name: "Course", Path: "courses", Table: "courses", + DefaultSort: "-created_date", DefaultLimit: 200, + Ops: OpList | OpGet | OpCreate | OpUpdate, + OrgNullable: true, + Columns: []Column{ + {Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true}, + {Name: "org_id", Kind: KindUUID, PGType: "uuid", ReadOnly: true}, + {Name: "title", Kind: KindString, PGType: "text", NotNull: true, Required: true}, + {Name: "description", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "category", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "difficulty", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "xp", Kind: KindInt, PGType: "int", NotNull: true}, + {Name: "estimated_minutes", Kind: KindInt, PGType: "int", NotNull: true}, + {Name: "badge_reward", Kind: KindString, PGType: "text"}, + {Name: "proof_skill", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "skill_id", Kind: KindString, PGType: "text"}, + {Name: "target_level", Kind: KindEnum, PGType: "skill_level", Enum: []string{"beginner", "intermediate", "advanced", "expert"}}, + {Name: "required_level", Kind: KindEnum, PGType: "skill_level", Enum: []string{"beginner", "intermediate", "advanced", "expert"}}, + {Name: "completion_criteria", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + {Name: "verification_criteria", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + {Name: "challenge", Kind: KindJSON, PGType: "jsonb", NotNull: true}, + {Name: "unlock_requirements", Kind: KindJSON, PGType: "jsonb", NotNull: true}, + {Name: "quiz", Kind: KindJSON, PGType: "jsonb", NotNull: true}, + {Name: "pass_score", Kind: KindInt, PGType: "int", NotNull: true}, + {Name: "status", Kind: KindEnum, PGType: "course_status", NotNull: true, Enum: []string{"active", "inactive"}}, + {Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + {Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + {Name: "training_outline", Kind: KindTextArray, PGType: "text[]", NotNull: true}, + }, + }, + { + Name: "LearningPath", Path: "learning-paths", Table: "learning_paths", + DefaultSort: "-created_date", DefaultLimit: 100, + Ops: OpList, + OrgNullable: true, + Columns: []Column{ + {Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true}, + {Name: "org_id", Kind: KindUUID, PGType: "uuid", ReadOnly: true}, + {Name: "name", Kind: KindString, PGType: "text", NotNull: true, Required: true}, + {Name: "target_role", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "description", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "difficulty", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "steps", Kind: KindJSON, PGType: "jsonb", NotNull: true}, + {Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + {Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + }, + }, + { + Name: "RoleCategory", Path: "role-categories", Table: "role_categories", + DefaultSort: "-created_date", DefaultLimit: 100, + Ops: OpList | OpCreate, + Columns: []Column{ + {Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true}, + {Name: "org_id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "name", Kind: KindString, PGType: "text", NotNull: true, Required: true}, + {Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + {Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + }, + }, + { + Name: "Certification", Path: "certifications", Table: "certifications", + DefaultSort: "-created_date", DefaultLimit: 200, + Ops: OpList | OpCreate | OpDelete, + Columns: []Column{ + {Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true}, + {Name: "org_id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "name", Kind: KindString, PGType: "text", NotNull: true, Required: true}, + {Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + {Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + }, + }, + { + Name: "UserActivity", Path: "user-activity", Table: "user_activity", + DefaultSort: "-created_date", DefaultLimit: 500, + Ops: OpList | OpCreate, + Columns: []Column{ + {Name: "id", Kind: KindInt, PGType: "bigint", NotNull: true, ReadOnly: true}, + {Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true}, + {Name: "org_id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "event_type", Kind: KindString, PGType: "text", NotNull: true, Required: true}, + {Name: "user_id", Kind: KindUUID, PGType: "uuid", ReadOnly: true}, + {Name: "user_email", Kind: KindString, PGType: "citext", NotNull: true, ReadOnly: true}, + {Name: "user_name", Kind: KindString, PGType: "text", NotNull: true, ReadOnly: true}, + {Name: "account_type", Kind: KindString, PGType: "text", NotNull: true, ReadOnly: true}, + {Name: "details", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "position_id", Kind: KindUUID, PGType: "uuid"}, + {Name: "application_id", Kind: KindUUID, PGType: "uuid"}, + {Name: "candidate_id", Kind: KindUUID, PGType: "uuid"}, + {Name: "interview_id", Kind: KindUUID, PGType: "uuid"}, + {Name: "worker_email", Kind: KindString, PGType: "citext"}, + {Name: "metadata", Kind: KindJSON, PGType: "jsonb"}, + {Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + }, + }, + { + Name: "Evidence", Path: "evidence", Table: "evidence", + DefaultSort: "-created_date", DefaultLimit: 200, + Ops: OpList | OpCreate | OpUpdate, + Columns: []Column{ + {Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true}, + {Name: "org_id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "course_id", Kind: KindUUID, PGType: "uuid"}, + {Name: "worker_profile_id", Kind: KindUUID, PGType: "uuid"}, + {Name: "course_title", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "skill", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "worker_email", Kind: KindString, PGType: "citext", NotNull: true, Required: true}, + {Name: "worker_name", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "type", Kind: KindEnum, PGType: "challenge_type", NotNull: true, Required: true, Enum: []string{"roleplay", "video", "photo_identify"}}, + {Name: "media_url", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "transcript", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "ai_verdict", Kind: KindEnum, PGType: "evidence_verdict", NotNull: true, Enum: []string{"verified", "needs_work", "failed"}}, + {Name: "ai_score", Kind: KindInt, PGType: "int", NotNull: true}, + {Name: "ai_rubric", Kind: KindJSON, PGType: "jsonb", NotNull: true}, + {Name: "ai_feedback", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "supervisor_verified", Kind: KindBool, PGType: "boolean", NotNull: true}, + {Name: "supervisor_name", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "verified_date", Kind: KindTimestamp, PGType: "timestamptz"}, + {Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + {Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + }, + }, + { + Name: "Assignment", Path: "assignments", Table: "assignments", + DefaultSort: "-created_date", DefaultLimit: 500, + Ops: OpList | OpCreate, + Columns: []Column{ + {Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true}, + {Name: "org_id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "job_posting_id", Kind: KindUUID, PGType: "uuid", NotNull: true, Required: true}, + {Name: "application_id", Kind: KindUUID, PGType: "uuid"}, + {Name: "worker_profile_id", Kind: KindUUID, PGType: "uuid"}, + {Name: "worker_email", Kind: KindString, PGType: "citext", NotNull: true, Required: true}, + {Name: "worker_name", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "starts_at", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, Required: true}, + {Name: "ends_at", Kind: KindTimestamp, PGType: "timestamptz"}, + {Name: "status", Kind: KindEnum, PGType: "assignment_status", NotNull: true, Enum: []string{"active", "completed", "cancelled"}}, + {Name: "source", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "match_score", Kind: KindInt, PGType: "int"}, + {Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + {Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + }, + }, + { + Name: "ShiftRecord", Path: "shift-records", Table: "shift_records", + DefaultSort: "-created_date", DefaultLimit: 500, + Ops: OpList, + Columns: []Column{ + {Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true}, + {Name: "org_id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "staff_id", Kind: KindUUID, PGType: "uuid"}, + {Name: "assignment_id", Kind: KindUUID, PGType: "uuid"}, + {Name: "job_posting_id", Kind: KindUUID, PGType: "uuid"}, + {Name: "worker_name", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "worker_email", Kind: KindString, PGType: "citext", NotNull: true}, + {Name: "role", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "role_category", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "shift_date", Kind: KindDate, PGType: "date", NotNull: true}, + {Name: "scheduled_start", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true}, + {Name: "scheduled_end", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true}, + {Name: "scheduled_hours", Kind: KindFloat, PGType: "numeric", NotNull: true}, + {Name: "actual_start", Kind: KindTimestamp, PGType: "timestamptz"}, + {Name: "actual_end", Kind: KindTimestamp, PGType: "timestamptz"}, + {Name: "actual_hours", Kind: KindFloat, PGType: "numeric", NotNull: true}, + {Name: "overtime_hours", Kind: KindFloat, PGType: "numeric", NotNull: true}, + {Name: "minutes_late", Kind: KindInt, PGType: "int", NotNull: true}, + {Name: "status", Kind: KindEnum, PGType: "shift_status", NotNull: true, Enum: []string{"present", "late", "absent", "no_show"}}, + {Name: "notes", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + {Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + }, + }, + // Badge serves NO endpoint: useBadges has zero consumers and every + // badge the UI renders comes from worker_profiles.earned_badges. The + // descriptor exists so the seeder can write the table. api-contract.md §2. + { + Name: "Badge", Path: "badges", Table: "badges", + DefaultSort: "-created_date", DefaultLimit: 200, + Ops: 0, + Columns: []Column{ + {Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true}, + {Name: "org_id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true}, + {Name: "name", Kind: KindString, PGType: "text", NotNull: true, Required: true}, + {Name: "description", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "image_url", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "level", Kind: KindEnum, PGType: "badge_level", NotNull: true, Enum: []string{"bronze", "silver", "gold", "platinum"}}, + {Name: "requirements", Kind: KindString, PGType: "text", NotNull: true}, + {Name: "expiration_months", Kind: KindInt, PGType: "int"}, + {Name: "verification_status", Kind: KindEnum, PGType: "badge_verification", NotNull: true, Enum: []string{"pending", "verified", "expired"}}, + {Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + {Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true}, + }, + }, +} diff --git a/go-api/internal/httpserver/api.go b/go-api/internal/httpserver/api.go new file mode 100644 index 0000000..fd2eb03 --- /dev/null +++ b/go-api/internal/httpserver/api.go @@ -0,0 +1,220 @@ +package httpserver + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/krow/krow-backend/go-api/internal/authctx" + "github.com/krow/krow-backend/go-api/internal/domain" + "github.com/krow/krow-backend/go-api/internal/service" +) + +// maxBodyBytes bounds a request body. The largest thing the frontend sends is +// an AI interview transcript; 4 MB is far above it and far below trouble. +const maxBodyBytes = 4 << 20 + +// routeResources registers exactly the endpoints each resource supports. +// +// Only the declared operations are registered, so an unsupported one — DELETE +// on a job posting, say — is answered by the mux with 405 rather than by a +// handler that has to know it should refuse. The database having a table is +// never a reason for an endpoint to exist. See api-contract.md §2. +func (s *Server) routeResources(mux *http.ServeMux) int { + count := 0 + for _, svc := range s.api.All() { + res := svc.Resource() + base := "/api/v1/" + res.Path + item := base + "/{id}" + + if res.Supports(domain.OpList) { + mux.HandleFunc("GET "+base, s.handleList(svc)) + count++ + } + if res.Supports(domain.OpCreate) { + mux.HandleFunc("POST "+base, s.handleCreate(svc)) + count++ + } + if res.Supports(domain.OpGet) { + mux.HandleFunc("GET "+item, s.handleGet(svc)) + count++ + } + if res.Supports(domain.OpUpdate) { + mux.HandleFunc("PATCH "+item, s.handleUpdate(svc)) + count++ + } + if res.Supports(domain.OpDelete) { + mux.HandleFunc("DELETE "+item, s.handleDelete(svc)) + count++ + } + } + return count +} + +// authorize is the role gate. It runs before any query. +// +// It answers 403 and nothing else — never 404, and never a message naming the +// role required. Which rows the caller may then see is a separate question, +// answered in SQL by the repository, and its refusal is a 404 so that existence +// does not leak. Keeping the two apart is what makes "403 means your role, 404 +// means not yours or not there" a rule a client can rely on. +// +// The role comes from the session-resolved identity. A role the API does not +// recognise authorizes nothing. +func (s *Server) authorize(w http.ResponseWriter, r *http.Request, + svc *service.Service, op domain.Op) (authctx.Identity, bool) { + + ident, err := authctx.MustFrom(r.Context()) + if err != nil { + // Unreachable: the middleware refuses an unauthenticated request before + // the router sees it. A missing identity here is a wiring bug, not a + // client error. + writeError(w, s.log, domain.Internal(err)) + return authctx.Identity{}, false + } + + role, known := domain.ParseRole(ident.Role) + if !known || !svc.Resource().Policy.Allows(op, role) { + s.log.Warn("authorization refused", + "user_id", ident.UserID, "role", ident.Role, + "resource", svc.Resource().Path, "method", r.Method, "path", r.URL.Path) + writeError(w, s.log, domain.Forbidden()) + return authctx.Identity{}, false + } + return ident, true +} + +func (s *Server) handleList(svc *service.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ident, ok := s.authorize(w, r, svc, domain.OpList) + if !ok { + return + } + params, err := svc.ParseList(r.URL.Query()) + if err != nil { + writeError(w, s.log, err) + return + } + page, err := svc.List(r.Context(), ident, params) + if err != nil { + writeError(w, s.log, err) + return + } + writePage(w, page) + } +} + +func (s *Server) handleGet(svc *service.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ident, ok := s.authorize(w, r, svc, domain.OpGet) + if !ok { + return + } + rec, err := svc.Get(r.Context(), ident, r.PathValue("id")) + if err != nil { + writeError(w, s.log, err) + return + } + writeRecord(w, http.StatusOK, rec) + } +} + +func (s *Server) handleCreate(svc *service.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ident, ok := s.authorize(w, r, svc, domain.OpCreate) + if !ok { + return + } + body, err := decodeBody(r) + if err != nil { + writeError(w, s.log, err) + return + } + rec, err := svc.Create(r.Context(), ident, body) + if err != nil { + writeError(w, s.log, err) + return + } + writeRecord(w, http.StatusCreated, rec) + } +} + +func (s *Server) handleUpdate(svc *service.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ident, ok := s.authorize(w, r, svc, domain.OpUpdate) + if !ok { + return + } + body, err := decodeBody(r) + if err != nil { + writeError(w, s.log, err) + return + } + rec, err := svc.Update(r.Context(), ident, r.PathValue("id"), body) + if err != nil { + writeError(w, s.log, err) + return + } + writeRecord(w, http.StatusOK, rec) + } +} + +func (s *Server) handleDelete(svc *service.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ident, ok := s.authorize(w, r, svc, domain.OpDelete) + if !ok { + return + } + rec, err := svc.Delete(r.Context(), ident, r.PathValue("id")) + if err != nil { + writeError(w, s.log, err) + return + } + writeRecord(w, http.StatusOK, rec) + } +} + +// decodeBody reads a JSON object body. +// +// DisallowUnknownFields is not used — the target is a map, so every field is +// "known" here. Unknown *columns* are rejected in the service, where the +// resource's schema is available to say which those are. +// decodeInto reads a JSON body into a typed struct. +// +// Beside decodeBody rather than replacing it: the resource handlers genuinely +// want the open map, because a PATCH body is "whichever fields the caller sent" +// and a struct cannot distinguish an absent field from a zero one. The auth +// endpoints have a fixed, closed shape, and a struct says so. +func decodeInto(r *http.Request, dst any) error { + defer func() { _ = r.Body.Close() }() + raw, err := io.ReadAll(http.MaxBytesReader(nil, r.Body, maxBodyBytes)) + if err != nil { + return domain.Invalid("request body could not be read") + } + if len(raw) == 0 { + return domain.Invalid("request body must be a JSON object") + } + if err := json.Unmarshal(raw, dst); err != nil { + return domain.Invalid("request body must be a JSON object") + } + return nil +} + +func decodeBody(r *http.Request) (domain.Record, error) { + defer func() { _ = r.Body.Close() }() + raw, err := io.ReadAll(http.MaxBytesReader(nil, r.Body, maxBodyBytes)) + if err != nil { + return nil, domain.Invalid("request body could not be read") + } + if len(raw) == 0 { + return domain.Record{}, nil + } + var body domain.Record + if err := json.Unmarshal(raw, &body); err != nil { + return nil, domain.Invalid("request body must be a JSON object") + } + if body == nil { + return domain.Record{}, nil + } + return body, nil +} diff --git a/go-api/internal/httpserver/api_test.go b/go-api/internal/httpserver/api_test.go new file mode 100644 index 0000000..641ddc7 --- /dev/null +++ b/go-api/internal/httpserver/api_test.go @@ -0,0 +1,1114 @@ +package httpserver_test + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sort" + "strings" + "testing" + "time" + + "github.com/krow/krow-backend/go-api/internal/db" + "github.com/krow/krow-backend/go-api/internal/httpserver" + "github.com/krow/krow-backend/go-api/internal/testutil" +) + +type api struct { + t *testing.T + handler http.Handler + orgID string + h *testutil.Harness + srv *httpserver.Server + + // The signed-in session every do() call carries, and who it belongs to. + cookie *http.Cookie + userID string + email string +} + +// newAPI builds a server and signs in as the seeded user. +// +// The sign-in is part of the harness rather than part of each test because +// every endpoint below now requires one: without it the thirty-odd existing +// tests in this file would all assert 401 instead of what they were written to +// check. They are unchanged; the cookie travels in do(). +func newAPI(t *testing.T, opts ...httpserver.Option) *api { + t.Helper() + h := testutil.New(t) + srv := newServer(t, h, nil, opts...) + + a := &api{t: t, handler: srv.Handler(), orgID: h.OrgID, h: h, srv: srv} + a.userID, a.email = seededUser(t, h.Pool) + setPassword(t, h.Pool, a.userID) + + result := signIn(t, a.handler, a.email, harnessPassword, false) + if result.code != http.StatusOK || result.cookie == nil { + t.Fatalf("the harness could not sign in: status %d, cookie %v", result.code, result.cookie) + } + a.cookie = result.cookie + return a +} + +type response struct { + code int + body map[string]any +} + +func (a *api) do(method, path string, payload any) response { + a.t.Helper() + var body io.Reader + if payload != nil { + raw, err := json.Marshal(payload) + if err != nil { + a.t.Fatalf("encode payload: %v", err) + } + body = bytes.NewReader(raw) + } + req := httptest.NewRequest(method, path, body) + if a.cookie != nil { + req.AddCookie(a.cookie) + } + rec := httptest.NewRecorder() + a.handler.ServeHTTP(rec, req) + + out := response{code: rec.Code} + if rec.Body.Len() > 0 { + if err := json.Unmarshal(rec.Body.Bytes(), &out.body); err != nil { + a.t.Fatalf("%s %s: response is not JSON: %s", method, path, rec.Body.String()) + } + } + return out +} + +// doAnon is do() without the session cookie: the request a signed-out browser, +// or anyone who has never signed in, actually sends. +func (a *api) doAnon(method, path string, payload any) response { + a.t.Helper() + var body io.Reader + if payload != nil { + raw, err := json.Marshal(payload) + if err != nil { + a.t.Fatalf("encode payload: %v", err) + } + body = bytes.NewReader(raw) + } + req := httptest.NewRequest(method, path, body) + rec := httptest.NewRecorder() + a.handler.ServeHTTP(rec, req) + + out := response{code: rec.Code} + if rec.Body.Len() > 0 { + _ = json.Unmarshal(rec.Body.Bytes(), &out.body) + } + return out +} + +// as is do() performed by a specific actor, for the role and ownership tests. +func (a *api) as(act actor, method, path string, payload any) response { + a.t.Helper() + var body io.Reader + if payload != nil { + raw, err := json.Marshal(payload) + if err != nil { + a.t.Fatalf("encode payload: %v", err) + } + body = bytes.NewReader(raw) + } + req := httptest.NewRequest(method, path, body) + if act.cookie != nil { + req.AddCookie(act.cookie) + } + rec := httptest.NewRecorder() + a.handler.ServeHTTP(rec, req) + + out := response{code: rec.Code} + if rec.Body.Len() > 0 { + _ = json.Unmarshal(rec.Body.Bytes(), &out.body) + } + return out +} + +// codeOrEmpty reads the contract's error code, or "" when the response carried +// no error envelope. Distinct from errCode, which fails the test when there is +// no error: the role matrix needs to look at successes and refusals alike. +func (r response) codeOrEmpty() string { + body, _ := r.body["error"].(map[string]any) + if body == nil { + return "" + } + code, _ := body["code"].(string) + return code +} + +// doWith is do() with a caller-supplied cookie, for tests that hold more than +// one session. +func (a *api) doWith(cookie *http.Cookie, method, path string) response { + a.t.Helper() + req := httptest.NewRequest(method, path, nil) + if cookie != nil { + req.AddCookie(cookie) + } + rec := httptest.NewRecorder() + a.handler.ServeHTTP(rec, req) + + out := response{code: rec.Code} + if rec.Body.Len() > 0 { + _ = json.Unmarshal(rec.Body.Bytes(), &out.body) + } + return out +} + +func (r response) records(t *testing.T) []map[string]any { + t.Helper() + raw, ok := r.body["data"].([]any) + if !ok { + t.Fatalf("expected a data array, got %#v", r.body) + } + out := make([]map[string]any, 0, len(raw)) + for _, e := range raw { + out = append(out, e.(map[string]any)) + } + return out +} + +func (r response) record(t *testing.T) map[string]any { + t.Helper() + rec, ok := r.body["data"].(map[string]any) + if !ok { + t.Fatalf("expected a data object, got %#v", r.body) + } + return rec +} + +func (r response) meta(t *testing.T) map[string]any { + t.Helper() + m, ok := r.body["meta"].(map[string]any) + if !ok { + t.Fatalf("expected meta, got %#v", r.body) + } + return m +} + +func (r response) errCode(t *testing.T) string { + t.Helper() + e, ok := r.body["error"].(map[string]any) + if !ok { + t.Fatalf("expected an error envelope, got %#v", r.body) + } + return e["code"].(string) +} + +/* ── Collections ────────────────────────────────────────────────────────── */ + +func TestListEveryResource(t *testing.T) { + a := newAPI(t) + // Every collection endpoint answers 200 with an envelope, seeded or not. + for _, path := range []string{ + "job-postings", "job-applications", "ai-interviews", "staff", "worker-profiles", + "courses", "learning-paths", "role-categories", "certifications", + "user-activity", "evidence", "assignments", "shift-records", + } { + r := a.do("GET", "/api/v1/"+path, nil) + if r.code != http.StatusOK { + t.Errorf("GET %s = %d, want 200", path, r.code) + continue + } + r.records(t) + r.meta(t) + } +} + +// Assignments are empty by design in the source dataset. An empty collection is +// 200 with an empty array, never a 404. api-contract.md §8. +func TestEmptyCollectionIs200(t *testing.T) { + a := newAPI(t) + r := a.do("GET", "/api/v1/assignments", nil) + if r.code != http.StatusOK { + t.Fatalf("code = %d, want 200", r.code) + } + if got := r.records(t); len(got) != 0 { + t.Fatalf("expected no assignments, got %d", len(got)) + } + if total := r.meta(t)["total"].(float64); total != 0 { + t.Errorf("meta.total = %v, want 0", total) + } +} + +// The default limit is the resource's own, taken from the frontend call site. +// job-applications is 200 sorted -ai_score; shift-records is 500. +func TestEndpointSpecificDefaults(t *testing.T) { + a := newAPI(t) + for _, tc := range []struct { + path string + limit float64 + }{ + {"job-postings", 100}, {"job-applications", 200}, {"shift-records", 500}, + {"worker-profiles", 500}, {"courses", 200}, {"user-activity", 500}, + {"ai-interviews", 100}, {"staff", 100}, {"role-categories", 100}, + {"certifications", 200}, {"evidence", 200}, {"assignments", 500}, + {"learning-paths", 100}, + } { + m := a.do("GET", "/api/v1/"+tc.path, nil).meta(t) + if m["limit"] != tc.limit { + t.Errorf("%s default limit = %v, want %v", tc.path, m["limit"], tc.limit) + } + } +} + +func TestLimitAndTruncationMeta(t *testing.T) { + a := newAPI(t) + r := a.do("GET", "/api/v1/job-applications?limit=5", nil) + recs, m := r.records(t), r.meta(t) + if len(recs) != 5 { + t.Fatalf("returned %d records, want 5", len(recs)) + } + if m["returned"] != float64(5) { + t.Errorf("meta.returned = %v, want 5", m["returned"]) + } + if m["total"] != float64(24) { + t.Errorf("meta.total = %v, want 24 (the total ignores the limit)", m["total"]) + } + if m["truncated"] != true { + t.Error("meta.truncated should be true when the page does not reach the total") + } + + full := a.do("GET", "/api/v1/job-applications", nil) + if full.meta(t)["truncated"] != false { + t.Error("meta.truncated should be false when everything fits") + } +} + +func TestOffsetPaging(t *testing.T) { + a := newAPI(t) + first := a.do("GET", "/api/v1/job-applications?limit=10", nil).records(t) + second := a.do("GET", "/api/v1/job-applications?limit=10&offset=10", nil).records(t) + if len(first) != 10 || len(second) != 10 { + t.Fatalf("page sizes = %d, %d", len(first), len(second)) + } + seen := map[string]bool{} + for _, r := range first { + seen[r["id"].(string)] = true + } + for _, r := range second { + if seen[r["id"].(string)] { + t.Fatalf("record %s appeared on both pages", r["id"]) + } + } +} + +/* ── Sorting ────────────────────────────────────────────────────────────── */ + +// The default sort is the resource's own: -ai_score for applications. +func TestDefaultSortIsResourceSpecific(t *testing.T) { + a := newAPI(t) + recs := a.do("GET", "/api/v1/job-applications", nil).records(t) + prev := 101.0 + for _, r := range recs { + score := r["ai_score"].(float64) + if score > prev { + t.Fatalf("applications are not sorted by -ai_score: %v after %v", score, prev) + } + prev = score + } + + profiles := a.do("GET", "/api/v1/worker-profiles", nil).records(t) + prev = 1e9 + for _, r := range profiles { + score := r["krow_score"].(float64) + if score > prev { + t.Fatalf("profiles are not sorted by -krow_score: %v after %v", score, prev) + } + prev = score + } +} + +// NULLS LAST in BOTH directions. store.js returns before applying the +// descending negation, so a null is greater than everything either way. +// PostgreSQL's default is NULLS FIRST on DESC, so the descending case is the +// one that breaks if the ordering is left implicit. api-contract.md §7.1. +func TestNullsSortLastInBothDirections(t *testing.T) { + a := newAPI(t) + + // Every seeded posting has a null start_date, so a deliberate mix is built + // here — otherwise the assertion passes trivially and proves nothing. + for _, d := range []any{"2026-09-01", nil, "2026-07-15", nil, "2026-08-20"} { + payload := map[string]any{"title": "Nulls Test"} + if d != nil { + payload["start_date"] = d + } + if r := a.do("POST", "/api/v1/job-postings", payload); r.code != http.StatusCreated { + t.Fatalf("setup create = %d: %#v", r.code, r.body) + } + } + + for _, sortSpec := range []string{"start_date", "-start_date"} { + recs := a.do("GET", "/api/v1/job-postings?sort="+sortSpec+"&limit=500", nil).records(t) + + var values []any + for _, r := range recs { + values = append(values, r["start_date"]) + } + firstNull := -1 + for i, v := range values { + if v == nil { + firstNull = i + break + } + } + if firstNull == -1 { + t.Fatalf("sort=%s: no nulls present, the test is not exercising anything", sortSpec) + } + for i := firstNull; i < len(values); i++ { + if values[i] != nil { + t.Fatalf("sort=%s: %v appears at position %d, after a null at %d — NULLS LAST is not applied", + sortSpec, values[i], i, firstNull) + } + } + if firstNull < 3 { + t.Fatalf("sort=%s: only %d non-null values sorted before the nulls, expected 3", + sortSpec, firstNull) + } + + // And the non-null values are genuinely ordered. + for i := 1; i < firstNull; i++ { + prev, cur := values[i-1].(string), values[i].(string) + if sortSpec == "start_date" && cur < prev { + t.Errorf("ascending order broken: %s after %s", cur, prev) + } + if sortSpec == "-start_date" && cur > prev { + t.Errorf("descending order broken: %s after %s", cur, prev) + } + } + } +} + +// PostgreSQL does not guarantee a stable sort. Every ORDER BY appends `, id` +// so repeated identical requests return the same order. api-contract.md §7.3. +func TestStableSortWithIDTiebreaker(t *testing.T) { + a := newAPI(t) + // Many applications share ai_score 0, so the tiebreaker decides their order. + var first []string + for attempt := 0; attempt < 5; attempt++ { + recs := a.do("GET", "/api/v1/job-applications?sort=-ai_score", nil).records(t) + ids := make([]string, 0, len(recs)) + for _, r := range recs { + ids = append(ids, r["id"].(string)) + } + if attempt == 0 { + first = ids + continue + } + for i := range ids { + if ids[i] != first[i] { + t.Fatalf("order changed between identical requests at position %d", i) + } + } + } + + // And the tiebreaker really is id: within a score group, ids ascend. + recs := a.do("GET", "/api/v1/job-applications?sort=-ai_score", nil).records(t) + for i := 1; i < len(recs); i++ { + if recs[i]["ai_score"] != recs[i-1]["ai_score"] { + continue + } + if recs[i]["id"].(string) < recs[i-1]["id"].(string) { + t.Fatalf("ids do not ascend within an equal-score group: %s after %s", + recs[i]["id"], recs[i-1]["id"]) + } + } +} + +func TestSortAscendingAndUnknownField(t *testing.T) { + a := newAPI(t) + recs := a.do("GET", "/api/v1/job-applications?sort=ai_score", nil).records(t) + prev := -1.0 + for _, r := range recs { + if score := r["ai_score"].(float64); score < prev { + t.Fatalf("ascending sort broken: %v after %v", score, prev) + } else { + prev = score + } + } + + r := a.do("GET", "/api/v1/job-applications?sort=-nonsense", nil) + if r.code != http.StatusBadRequest { + t.Errorf("unknown sort field = %d, want 400", r.code) + } + if code := r.errCode(t); code != "invalid_query" { + t.Errorf("error code = %q, want invalid_query", code) + } +} + +/* ── Filtering ──────────────────────────────────────────────────────────── */ + +func TestFilterEquality(t *testing.T) { + a := newAPI(t) + postings := a.do("GET", "/api/v1/job-postings", nil).records(t) + var target string + for _, p := range postings { + if p["legacy_id"] == "job_security" { + target = p["id"].(string) + } + } + if target == "" { + t.Fatal("job_security posting not found") + } + + recs := a.do("GET", "/api/v1/job-applications?job_posting_id="+target, nil).records(t) + if len(recs) != 6 { + t.Errorf("applications for job_security = %d, want 6", len(recs)) + } + for _, r := range recs { + if r["job_posting_id"] != target { + t.Errorf("filter leaked a record from posting %v", r["job_posting_id"]) + } + } +} + +// An array-valued query parameter means membership, matching store.js's +// `Array.isArray(want) ? want.includes(got)`. api-contract.md §6. +func TestFilterArrayMeansIN(t *testing.T) { + a := newAPI(t) + recs := a.do("GET", "/api/v1/job-applications?status=hired&status=interview", nil).records(t) + if len(recs) != 8 { + t.Errorf("hired+interview = %d, want 8 (3 hired, 5 interview)", len(recs)) + } + for _, r := range recs { + if s := r["status"].(string); s != "hired" && s != "interview" { + t.Errorf("membership filter leaked status %q", s) + } + } +} + +// Email columns are citext, so matching is case-insensitive server-side. +// api-contract.md §6.1. +func TestFilterEmailIsCaseInsensitive(t *testing.T) { + a := newAPI(t) + lower := a.do("GET", "/api/v1/worker-profiles?email=maria.gonzalez@example.com", nil).records(t) + upper := a.do("GET", "/api/v1/worker-profiles?email=MARIA.GONZALEZ@EXAMPLE.COM", nil).records(t) + if len(lower) != 1 { + t.Fatalf("expected exactly one profile, got %d", len(lower)) + } + if len(upper) != len(lower) { + t.Errorf("case-insensitive match failed: %d vs %d", len(upper), len(lower)) + } +} + +func TestFilterRejectsUnknownAndUnfilterableFields(t *testing.T) { + a := newAPI(t) + if r := a.do("GET", "/api/v1/job-postings?nonsense=1", nil); r.code != http.StatusBadRequest { + t.Errorf("unknown filter field = %d, want 400", r.code) + } + // Arrays are not filterable: store.js compares with === and matches nothing, + // so supporting containment here would be a silent behaviour change. + if r := a.do("GET", "/api/v1/job-postings?responsibilities=x", nil); r.code != http.StatusBadRequest { + t.Errorf("array filter = %d, want 400", r.code) + } + if r := a.do("GET", "/api/v1/job-postings?vetting_criteria=x", nil); r.code != http.StatusBadRequest { + t.Errorf("jsonb filter = %d, want 400", r.code) + } +} + +/* ── Get ────────────────────────────────────────────────────────────────── */ + +func TestGetAndNotFound(t *testing.T) { + a := newAPI(t) + postings := a.do("GET", "/api/v1/job-postings", nil).records(t) + id := postings[0]["id"].(string) + + r := a.do("GET", "/api/v1/job-postings/"+id, nil) + if r.code != http.StatusOK { + t.Fatalf("get = %d, want 200", r.code) + } + if r.record(t)["id"] != id { + t.Error("returned the wrong record") + } + + missing := a.do("GET", "/api/v1/job-postings/00000000-0000-0000-0000-000000000000", nil) + if missing.code != http.StatusNotFound { + t.Errorf("missing record = %d, want 404", missing.code) + } + if code := missing.errCode(t); code != "not_found" { + t.Errorf("error code = %q, want not_found", code) + } + // store.js throws " not found" using the frontend entity name. + msg := missing.body["error"].(map[string]any)["message"].(string) + if want := "JobPosting 00000000-0000-0000-0000-000000000000 not found"; msg != want { + t.Errorf("message = %q, want %q", msg, want) + } + + // A malformed id is simply an id that cannot be found. + if r := a.do("GET", "/api/v1/job-postings/not-a-uuid", nil); r.code != http.StatusNotFound { + t.Errorf("malformed id = %d, want 404", r.code) + } +} + +/* ── Create ─────────────────────────────────────────────────────────────── */ + +func TestCreateAppliesDefaultsAndReturnsWholeRecord(t *testing.T) { + a := newAPI(t) + r := a.do("POST", "/api/v1/job-postings", map[string]any{"title": "Test Bartender"}) + if r.code != http.StatusCreated { + t.Fatalf("create = %d, want 201: %#v", r.code, r.body) + } + rec := r.record(t) + if rec["title"] != "Test Bartender" { + t.Errorf("title = %v", rec["title"]) + } + // The response is the complete record, defaults included. + for _, field := range []string{"id", "created_date", "updated_date", "status", "vetting_criteria", "responsibilities"} { + if _, ok := rec[field]; !ok { + t.Errorf("created record is missing %s", field) + } + } + if rec["status"] != "draft" { + t.Errorf("default status = %v, want draft", rec["status"]) + } + if rec["headcount"] != float64(1) { + t.Errorf("default headcount = %v, want 1", rec["headcount"]) + } +} + +func TestCreateRejectsMissingRequiredAndBlank(t *testing.T) { + a := newAPI(t) + r := a.do("POST", "/api/v1/job-postings", map[string]any{}) + if r.code != http.StatusUnprocessableEntity { + t.Fatalf("missing title = %d, want 422", r.code) + } + if code := r.errCode(t); code != "validation_failed" { + t.Errorf("code = %q, want validation_failed", code) + } + if r := a.do("POST", "/api/v1/job-postings", map[string]any{"title": " "}); r.code != http.StatusUnprocessableEntity { + t.Errorf("blank title = %d, want 422", r.code) + } +} + +// Unknown fields are rejected, not ignored. Silently dropping them is exactly +// how interview_id, training_outline and score_breakdown would have been lost. +func TestCreateRejectsUnknownFields(t *testing.T) { + a := newAPI(t) + r := a.do("POST", "/api/v1/job-postings", map[string]any{"title": "X", "not_a_column": 1}) + if r.code != http.StatusUnprocessableEntity { + t.Fatalf("unknown field = %d, want 422", r.code) + } + details := r.body["error"].(map[string]any)["details"].(map[string]any) + if details["not_a_column"] == nil { + t.Errorf("the offending field is not named in details: %#v", details) + } +} + +// Server-owned fields are ignored rather than rejected. api-contract.md §3.1. +func TestCreateIgnoresServerOwnedFields(t *testing.T) { + a := newAPI(t) + r := a.do("POST", "/api/v1/job-postings", map[string]any{ + "title": "Ignore Me", + "id": "11111111-1111-1111-1111-111111111111", + "created_date": "2001-01-01T00:00:00.000Z", + }) + if r.code != http.StatusCreated { + t.Fatalf("create = %d, want 201: %#v", r.code, r.body) + } + rec := r.record(t) + if rec["id"] == "11111111-1111-1111-1111-111111111111" { + t.Error("a client-supplied id was honoured") + } + if rec["created_date"] == "2001-01-01T00:00:00.000Z" { + t.Error("a client-supplied created_date was honoured") + } +} + +func TestCreateRejectsInvalidEnum(t *testing.T) { + a := newAPI(t) + r := a.do("POST", "/api/v1/job-postings", map[string]any{"title": "X", "status": "archived"}) + if r.code != http.StatusUnprocessableEntity { + t.Fatalf("invalid enum = %d, want 422", r.code) + } + details := r.body["error"].(map[string]any)["details"].(map[string]any) + if details["status"] == nil { + t.Error("details should name the status field") + } +} + +func TestCreateEnforcesForeignKeys(t *testing.T) { + a := newAPI(t) + r := a.do("POST", "/api/v1/job-applications", map[string]any{ + "job_posting_id": "00000000-0000-0000-0000-000000000000", + "applicant_name": "Nobody", + "email": "nobody@example.com", + }) + if r.code != http.StatusUnprocessableEntity { + t.Fatalf("dangling foreign key = %d, want 422: %#v", r.code, r.body) + } +} + +func TestCreateEnforcesUniqueness(t *testing.T) { + a := newAPI(t) + apps := a.do("GET", "/api/v1/job-applications", nil).records(t) + existing := apps[0] + + r := a.do("POST", "/api/v1/job-applications", map[string]any{ + "job_posting_id": existing["job_posting_id"], + "applicant_name": "Duplicate", + "email": existing["email"], + }) + if r.code != http.StatusConflict { + t.Fatalf("duplicate (job_posting_id, email) = %d, want 409: %#v", r.code, r.body) + } + if code := r.errCode(t); code != "conflict" { + t.Errorf("code = %q, want conflict", code) + } +} + +/* ── Update ─────────────────────────────────────────────────────────────── */ + +// PATCH is a shallow merge: absent keys are untouched, and a supplied object +// REPLACES rather than merging into the stored one. api-contract.md §3.2. +func TestPatchIsShallowMerge(t *testing.T) { + a := newAPI(t) + created := a.do("POST", "/api/v1/job-postings", map[string]any{ + "title": "Shallow", "company": "Acme", "location": "Nowhere", + "responsibilities": []string{"a", "b"}, + }).record(t) + id := created["id"].(string) + + patched := a.do("PATCH", "/api/v1/job-postings/"+id, + map[string]any{"location": "Somewhere"}).record(t) + + if patched["location"] != "Somewhere" { + t.Errorf("location = %v, want Somewhere", patched["location"]) + } + if patched["company"] != "Acme" { + t.Errorf("an untouched field changed: company = %v", patched["company"]) + } + if patched["title"] != "Shallow" { + t.Errorf("an untouched field changed: title = %v", patched["title"]) + } + + // A nested object is replaced wholesale, not deep-merged. useSubmitChallenge + // depends on whole arrays being replaced rather than appended to. + withCriteria := a.do("PATCH", "/api/v1/job-postings/"+id, + map[string]any{"vetting_criteria": map[string]any{"experience": 30}}).record(t) + vc := withCriteria["vetting_criteria"].(map[string]any) + if len(vc) != 1 || vc["experience"] != float64(30) { + t.Errorf("vetting_criteria was deep-merged, not replaced: %#v", vc) + } + + // Same for arrays. + withArray := a.do("PATCH", "/api/v1/job-postings/"+id, + map[string]any{"responsibilities": []string{"z"}}).record(t) + resp := withArray["responsibilities"].([]any) + if len(resp) != 1 || resp[0] != "z" { + t.Errorf("responsibilities were appended rather than replaced: %#v", resp) + } +} + +func TestPatchUpdatesTimestampAndMissingIs404(t *testing.T) { + a := newAPI(t) + created := a.do("POST", "/api/v1/job-postings", map[string]any{"title": "Stamped"}).record(t) + id := created["id"].(string) + + time.Sleep(5 * time.Millisecond) + patched := a.do("PATCH", "/api/v1/job-postings/"+id, map[string]any{"title": "Restamped"}).record(t) + if patched["updated_date"] == created["updated_date"] { + t.Error("updated_date did not move on PATCH") + } + if patched["created_date"] != created["created_date"] { + t.Error("created_date changed on PATCH") + } + + missing := a.do("PATCH", "/api/v1/job-postings/00000000-0000-0000-0000-000000000000", + map[string]any{"title": "Ghost"}) + if missing.code != http.StatusNotFound { + t.Errorf("patch on a missing record = %d, want 404", missing.code) + } +} + +// The interview_id round trip: the exact write AIInterviewModal.jsx:180 makes. +func TestPatchApplicationInterviewID(t *testing.T) { + a := newAPI(t) + apps := a.do("GET", "/api/v1/job-applications?limit=1", nil).records(t) + interviews := a.do("GET", "/api/v1/ai-interviews", nil).records(t) + if len(apps) == 0 || len(interviews) == 0 { + t.Fatal("need a seeded application and interview") + } + id := apps[0]["id"].(string) + interviewID := interviews[0]["id"].(string) + + rec := a.do("PATCH", "/api/v1/job-applications/"+id, map[string]any{ + "status": "interview", "interview_id": interviewID, "ai_score": 81, + }).record(t) + + if rec["interview_id"] != interviewID { + t.Errorf("interview_id = %v, want %v", rec["interview_id"], interviewID) + } + if rec["status"] != "interview" { + t.Errorf("status = %v", rec["status"]) + } + if rec["ai_score"] != float64(81) { + t.Errorf("ai_score = %v", rec["ai_score"]) + } +} + +// The two other reconciliation columns, round-tripped. +func TestPatchTrainingOutlineAndProfileScoreBreakdown(t *testing.T) { + a := newAPI(t) + + courses := a.do("GET", "/api/v1/courses?limit=1", nil).records(t) + course := a.do("PATCH", "/api/v1/courses/"+courses[0]["id"].(string), map[string]any{ + "training_outline": []string{"Mise en place", "Service", "Close down"}, + }).record(t) + outline, ok := course["training_outline"].([]any) + if !ok || len(outline) != 3 || outline[0] != "Mise en place" { + t.Errorf("training_outline did not round-trip: %#v", course["training_outline"]) + } + + profiles := a.do("GET", "/api/v1/worker-profiles?limit=1", nil).records(t) + profile := a.do("PATCH", "/api/v1/worker-profiles/"+profiles[0]["id"].(string), map[string]any{ + "score_breakdown": map[string]any{"reliability": 88, "experience": 71}, + }).record(t) + sb, ok := profile["score_breakdown"].(map[string]any) + if !ok || sb["reliability"] != float64(88) { + t.Errorf("score_breakdown did not round-trip: %#v", profile["score_breakdown"]) + } +} + +/* ── Delete ─────────────────────────────────────────────────────────────── */ + +// DELETE is idempotent and returns { id } whether or not a row went, because +// store.js never throws and both live callers delete inside loops without +// checking. api-contract.md §12.7. +func TestDeleteIsIdempotent(t *testing.T) { + a := newAPI(t) + apps := a.do("GET", "/api/v1/job-applications?limit=1", nil).records(t) + id := apps[0]["id"].(string) + + first := a.do("DELETE", "/api/v1/job-applications/"+id, nil) + if first.code != http.StatusOK { + t.Fatalf("delete = %d, want 200", first.code) + } + if first.record(t)["id"] != id { + t.Error("delete did not return the id") + } + + // Gone, and deleting again still succeeds. + if r := a.do("GET", "/api/v1/job-applications?limit=500", nil); len(r.records(t)) != 23 { + t.Errorf("after delete there are %d applications, want 23", len(r.records(t))) + } + second := a.do("DELETE", "/api/v1/job-applications/"+id, nil) + if second.code != http.StatusOK { + t.Errorf("second delete = %d, want 200 (idempotent)", second.code) + } + missing := a.do("DELETE", "/api/v1/job-applications/00000000-0000-0000-0000-000000000000", nil) + if missing.code != http.StatusOK { + t.Errorf("delete of a never-existing record = %d, want 200", missing.code) + } + malformed := a.do("DELETE", "/api/v1/job-applications/not-a-uuid", nil) + if malformed.code != http.StatusOK { + t.Errorf("delete with a malformed id = %d, want 200", malformed.code) + } +} + +/* ── Route surface ──────────────────────────────────────────────────────── */ + +// The database having a table is never a reason for an endpoint to exist. +func TestUnsupportedOperationsAreNotRouted(t *testing.T) { + a := newAPI(t) + postings := a.do("GET", "/api/v1/job-postings", nil).records(t) + id := postings[0]["id"].(string) + + // Nothing in the frontend deletes a job posting. + if r := a.do("DELETE", "/api/v1/job-postings/"+id, nil); r.code != http.StatusMethodNotAllowed { + t.Errorf("DELETE job-postings = %d, want 405", r.code) + } + // Shift records are read-only: U1 is unresolved, so there is no write path. + if r := a.do("POST", "/api/v1/shift-records", map[string]any{}); r.code != http.StatusMethodNotAllowed { + t.Errorf("POST shift-records = %d, want 405", r.code) + } + // Assignments are listed and created, never fetched by id or updated — so + // no item route exists for them at all, and a wrong method is a 404 rather + // than a 405 (405 needs the path pattern to exist under another method). + if r := a.do("PATCH", "/api/v1/assignments/"+id, map[string]any{}); r.code != http.StatusNotFound { + t.Errorf("PATCH assignments = %d, want 404", r.code) + } + // Badge has a table and is seeded, but useBadges has zero consumers. + if r := a.do("GET", "/api/v1/badges", nil); r.code != http.StatusNotFound { + t.Errorf("GET badges = %d, want 404 (no route registered)", r.code) + } + // The mux's own 404/405 replies are rewritten into the error envelope, so + // every response from the API is JSON. + if code := a.do("DELETE", "/api/v1/job-postings/"+id, nil).errCode(t); code != "method_not_allowed" { + t.Errorf("405 error code = %q, want method_not_allowed", code) + } + if code := a.do("GET", "/api/v1/badges", nil).errCode(t); code != "not_found" { + t.Errorf("404 error code = %q, want not_found", code) + } + // Job postings have no filter call site but are still gettable by id. + if r := a.do("GET", "/api/v1/job-postings/"+id, nil); r.code != http.StatusOK { + t.Errorf("GET job-postings/{id} = %d, want 200", r.code) + } +} + +/* ── Current user ───────────────────────────────────────────────────────── */ + +func TestCurrentUserAndPreferences(t *testing.T) { + a := newAPI(t) + + me := a.do("GET", "/api/v1/me", nil) + if me.code != http.StatusOK { + t.Fatalf("GET /me = %d", me.code) + } + user := me.record(t) + if user["email"] != "demo@krow.app" { + t.Errorf("email = %v, want demo@krow.app", user["email"]) + } + // krowHooks.js:42 reads user?.preferences straight off this object. + prefs, ok := user["preferences"].(map[string]any) + if !ok { + t.Fatalf("preferences are not embedded in the user: %#v", user) + } + if prefs["owliverDefault"] != true { + t.Errorf("owliverDefault = %v, want true", prefs["owliverDefault"]) + } + + updated := a.do("PATCH", "/api/v1/me", map[string]any{"full_name": "Alex R."}).record(t) + if updated["full_name"] != "Alex R." { + t.Errorf("full_name = %v", updated["full_name"]) + } + + // Preferences shallow-merge, and unknown keys land in the extra blob — + // which is where customSkills and customAgents live. + merged := a.do("PATCH", "/api/v1/me/preferences", map[string]any{ + "compactDensity": true, + "customSkills": []any{map[string]any{"id": "s1"}}, + }).record(t) + if merged["compactDensity"] != true { + t.Errorf("compactDensity = %v, want true", merged["compactDensity"]) + } + if merged["owliverDefault"] != true { + t.Errorf("an untouched preference changed: owliverDefault = %v", merged["owliverDefault"]) + } + if merged["customSkills"] == nil { + t.Error("an arbitrary preference key was not preserved") + } + + reread := a.do("GET", "/api/v1/me/preferences", nil).record(t) + if reread["compactDensity"] != true || reread["customSkills"] == nil { + t.Errorf("preferences did not survive a re-read: %#v", reread) + } + + if r := a.do("PATCH", "/api/v1/me/preferences", map[string]any{"emailDigest": "yes"}); r.code != http.StatusUnprocessableEntity { + t.Errorf("non-boolean preference = %d, want 422", r.code) + } +} + +/* ── Organization scoping ───────────────────────────────────────────────── */ + +// Reads are scoped: a record belonging to another organization is invisible, +// and is a 404 by id rather than a leak. +func TestOrganizationScoping(t *testing.T) { + a := newAPI(t) + ctx := t.Context() + + var otherOrg string + if err := a.h.Pool.QueryRow(ctx, + `INSERT INTO organizations (name, slug) VALUES ('Other Co', 'other-co') RETURNING id::text`). + Scan(&otherOrg); err != nil { + t.Fatalf("create second organization: %v", err) + } + var hidden string + if err := a.h.Pool.QueryRow(ctx, + `INSERT INTO job_postings (org_id, title) VALUES ($1::uuid, 'Hidden Role') RETURNING id::text`, + otherOrg).Scan(&hidden); err != nil { + t.Fatalf("create foreign posting: %v", err) + } + + for _, r := range a.do("GET", "/api/v1/job-postings?limit=500", nil).records(t) { + if r["id"] == hidden { + t.Fatal("a posting from another organization appeared in the list") + } + } + if r := a.do("GET", "/api/v1/job-postings/"+hidden, nil); r.code != http.StatusNotFound { + t.Errorf("foreign record by id = %d, want 404", r.code) + } + if r := a.do("PATCH", "/api/v1/job-postings/"+hidden, map[string]any{"title": "Stolen"}); r.code != http.StatusNotFound { + t.Errorf("patching a foreign record = %d, want 404", r.code) + } + + // It is still there — scoping hid it, it did not delete it. + var still int + if err := a.h.Pool.QueryRow(ctx, + `SELECT count(*) FROM job_postings WHERE id = $1::uuid AND title = 'Hidden Role'`, hidden). + Scan(&still); err != nil { + t.Fatal(err) + } + if still != 1 { + t.Error("the foreign record was modified or removed") + } +} + +// Courses with a NULL org_id are the shared platform library and must be +// visible to every organization. +func TestPlatformLibraryIsVisible(t *testing.T) { + a := newAPI(t) + var shared string + if err := a.h.Pool.QueryRow(t.Context(), + `INSERT INTO courses (org_id, title) VALUES (NULL, 'Platform Course') RETURNING id::text`). + Scan(&shared); err != nil { + t.Fatalf("insert shared course: %v", err) + } + found := false + for _, r := range a.do("GET", "/api/v1/courses?limit=500", nil).records(t) { + if r["id"] == shared { + found = true + } + } + if !found { + t.Error("a NULL-org course was not visible to the organization") + } +} + +/* ── Representation ─────────────────────────────────────────────────────── */ + +// Field names and value shapes must match what the frontend has always seen. +func TestRecordRepresentation(t *testing.T) { + a := newAPI(t) + rec := a.do("GET", "/api/v1/job-applications?limit=1", nil).records(t)[0] + + if _, ok := rec["id"].(string); !ok { + t.Errorf("id is %T, want a string", rec["id"]) + } + created, ok := rec["created_date"].(string) + if !ok || len(created) != 24 || created[len(created)-1] != 'Z' { + t.Errorf("created_date = %v; want ISO-8601 with milliseconds", rec["created_date"]) + } + if _, ok := rec["ai_score"].(float64); !ok { + t.Errorf("ai_score is %T, want a number", rec["ai_score"]) + } + if _, ok := rec["skills"].([]any); !ok { + t.Errorf("skills is %T, want an array", rec["skills"]) + } + if _, ok := rec["score_breakdown"].(map[string]any); !ok { + t.Errorf("score_breakdown is %T, want an object", rec["score_breakdown"]) + } + if _, ok := rec["client_rating"].(float64); !ok { + t.Errorf("client_rating is %T, want a number", rec["client_rating"]) + } + + staff := a.do("GET", "/api/v1/staff?limit=1", nil).records(t)[0] + if hire, ok := staff["hire_date"].(string); !ok || len(hire) != 10 { + t.Errorf("hire_date = %v, want YYYY-MM-DD", staff["hire_date"]) + } +} + +func TestHealthEndpoint(t *testing.T) { + a := newAPI(t) + r := a.do("GET", "/health", nil) + if r.code != http.StatusOK { + t.Fatalf("health = %d, want 200", r.code) + } + if r.body["status"] != "ok" { + t.Errorf("status = %v, want ok", r.body["status"]) + } + // The body is exactly one field. /health is unauthenticated, so anything + // added here is added to the public internet. + if len(r.body) != 1 { + t.Errorf("the health body has %d fields (%v), want exactly 1", len(r.body), keysOf(r.body)) + } +} + +// TestHealthLeaksNoInfrastructure is the assertion that has to survive future +// edits to the handler: whatever else /health says, it must not describe the +// machine it is running on. +// +// It checks the rendered body rather than the struct, because the leak that +// matters is the one a caller can read — a field added to an embedded type, or +// a struct swapped in wholesale, would pass a field-by-field test on +// healthResponse and fail this one. +func TestHealthLeaksNoInfrastructure(t *testing.T) { + a := newAPI(t) + + rec := httptest.NewRecorder() + a.handler.ServeHTTP(rec, httptest.NewRequest("GET", "/health", nil)) + body := rec.Body.String() + + if rec.Code != http.StatusOK { + t.Fatalf("health = %d, want 200", rec.Code) + } + + // Field names that would each be a disclosure on their own. + for _, key := range []string{ + "version", "postgres", "database", "schema", "table_count", + "migration", "applied_migration", "dirty", "error", "env", + "host", "port", "dsn", "user", "password", "latency", + } { + if strings.Contains(strings.ToLower(body), key) { + t.Errorf("the health response mentions %q:\n%s", key, body) + } + } + + // And the values themselves, taken from the live check rather than + // hardcoded, so this keeps working on a different server or database. + health := (&db.DB{Pool: a.h.Pool, Schema: "public"}).Check(context.Background()) + if health.Database == "" || health.Version == "" { + t.Fatal("the internal check returned nothing to compare against") + } + for name, secret := range map[string]string{ + "database name": health.Database, + "PostgreSQL version": health.Version, + "schema name": health.Schema, + } { + if strings.Contains(body, secret) { + t.Errorf("the health response contains the %s:\n%s", name, body) + } + } + // The internal check still gathers all of it — this change moved the + // audience, it did not remove the diagnostic. + if !health.Reachable || !health.SchemaPresent || health.TableCount == 0 { + t.Error("db.Check no longer reports the database detail it used to") + } +} + +// An unreachable database must be reported as unserviceable without saying why: +// the connection error text names the host, port, user and database. +func TestHealthUnavailableSaysNothingAboutWhy(t *testing.T) { + h := testutil.New(t) + srv := newServer(t, h, nil) + // Closing the pool is the fastest honest way to make the database + // unreachable: every Acquire fails immediately, with no network involved. + // The harness drops its database over a separate admin connection, so + // cleanup is unaffected. + h.Pool.Close() + + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/health", nil)) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("health with a dead database = %d, want 503", rec.Code) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("response is not JSON: %s", rec.Body.String()) + } + if body["status"] != "unavailable" { + t.Errorf("status = %v, want unavailable", body["status"]) + } + if len(body) != 1 { + t.Errorf("the unhealthy body has %d fields (%v), want exactly 1", len(body), keysOf(body)) + } + if strings.Contains(strings.ToLower(rec.Body.String()), "error") { + t.Errorf("the unhealthy response carries the connection error:\n%s", rec.Body.String()) + } +} + +func keysOf(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/go-api/internal/httpserver/auth.go b/go-api/internal/httpserver/auth.go new file mode 100644 index 0000000..227cb8d --- /dev/null +++ b/go-api/internal/httpserver/auth.go @@ -0,0 +1,374 @@ +package httpserver + +import ( + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/krow/krow-backend/go-api/internal/auth" + "github.com/krow/krow-backend/go-api/internal/authctx" + "github.com/krow/krow-backend/go-api/internal/domain" + "github.com/krow/krow-backend/go-api/internal/orgctx" +) + +// The authentication surface: sign in, sign out, and the middleware that turns +// a cookie into an identity. +// +// The shape of the whole thing is one sentence: the browser holds an opaque +// random string it cannot read, the database holds SHA-256 of that string, and +// every protected request is a lookup from one to the other. No claim travels +// in the request. There is no token in a JSON body, no user id in a query +// string, no organization in a header — those are all things a client can +// write, and a client writing its own identity is the bug this replaces. + +// sessionCookieName is the cookie the browser holds. +// +// The "__Host-" prefix would be stronger — browsers enforce Secure, Path=/ and +// no Domain on it — but it also *requires* Secure, which cannot be set over +// plain HTTP on localhost. A cookie name that only works in production is worse +// than a plain one that works everywhere, so the hardening is done by the +// attributes below instead, where it can be conditional. +const sessionCookieName = "krow_session" + +/* ── Cookie ─────────────────────────────────────────────────────────────── */ + +// secureCookies reports whether Secure may be set. +// +// Secure means "only ever send this over HTTPS". Setting it in development +// would mean the browser silently declines to send the cookie back to +// http://localhost, and the symptom is an endless loop of successful logins +// that never authenticate anything. +func (s *Server) secureCookies() bool { return s.cfg.AppEnv != "development" } + +// setSessionCookie writes the raw token to the browser. +// +// This is the only place the raw token is written to a response, and it goes +// into a Set-Cookie header rather than a body: HttpOnly means no script on the +// page can read it, which is what makes an XSS bug stop short of session theft. +// +// maxAge matches the session's own lifetime so the browser drops the cookie at +// roughly the moment the server would refuse it. The server is still the +// authority — a cookie the browser keeps too long is simply rejected — but a +// cookie that expires with its session keeps the two honest. +func (s *Server) setSessionCookie(w http.ResponseWriter, token string, lifetime time.Duration) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: token, + Path: "/", + // HttpOnly: script cannot read it. + HttpOnly: true, + // Lax, not Strict and not None. Strict would drop the cookie on any + // cross-site navigation, so following a link into the app would land on + // a login page despite a live session. None would require Secure and + // would send the cookie on cross-site POSTs, which is the CSRF hole Lax + // exists to close. + SameSite: http.SameSiteLaxMode, + Secure: s.secureCookies(), + MaxAge: int(lifetime.Seconds()), + }) +} + +// clearSessionCookie expires the cookie in the browser. +// +// The attributes must match the ones it was set with — a cookie is identified +// by name, domain and path, so clearing it with a different Path leaves the +// original in place and the browser keeps sending a token the server has +// already deleted. +func (s *Server) clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: s.secureCookies(), + MaxAge: -1, + }) +} + +// sessionToken reads the raw token out of the request, if there is one. +func sessionToken(r *http.Request) string { + c, err := r.Cookie(sessionCookieName) + if err != nil || c == nil { + return "" + } + return strings.TrimSpace(c.Value) +} + +/* ── Routes ─────────────────────────────────────────────────────────────── */ + +func (s *Server) routeAuth(mux *http.ServeMux) int { + mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin) + mux.HandleFunc("POST /api/v1/auth/logout", s.handleLogout) + return 2 +} + +// loginRequest is the body of POST /api/v1/auth/login. +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` + RememberMe bool `json:"remember_me"` +} + +// handleLogin verifies a password and issues a session. +// +// The order of operations is deliberate: +// +// 1. Parse and validate the *shape* of the request. A missing field is a +// malformed request, not a failed login, and saying so reveals nothing. +// 2. Check the rate limit, before any expensive work. Refusing early is the +// point — an attacker must not be able to make the server hash for them. +// 3. Verify the credentials, which takes the same measurable time whether the +// email exists or not (see auth.Credentials). +// 4. Issue the session and set the cookie. +// +// Every failure in step 3 produces one identical response. The reason goes to +// the log, at warn, with the email — which is already in the request — and +// never the password. +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := decodeInto(r, &req); err != nil { + writeError(w, s.log, err) + return + } + + email := strings.TrimSpace(req.Email) + details := map[string]string{} + if email == "" { + details["email"] = "an email address is required" + } + if req.Password == "" { + details["password"] = "a password is required" + } + if len(details) > 0 { + writeError(w, s.log, domain.Validation("email and password are required", details)) + return + } + + // Two budgets, both consulted, both counted. The per-email budget stops one + // account being ground down from many addresses; the per-address budget, + // which is wider, stops one host working through many accounts. They are + // separate limiters because they are deliberately different sizes — see the + // note on Server. + addr := clientAddr(r) + emailKey := strings.ToLower(email) + for _, check := range []struct { + limiter *attemptLimiter + key string + scope string + }{ + {s.loginByEmail, emailKey, "email"}, + {s.loginByAddr, addr, "address"}, + } { + if ok, retryAfter := check.limiter.Allow(check.key); !ok { + w.Header().Set("Retry-After", retryAfterSeconds(retryAfter)) + s.log.Warn("login rate limited", "scope", check.scope, + "email", email, "addr", addr, + "retry_after_seconds", retryAfterSeconds(retryAfter)) + writeError(w, s.log, domain.RateLimited( + "too many sign-in attempts; wait a few minutes and try again")) + return + } + } + + user, reason, err := s.credentials.Verify(r.Context(), email, req.Password) + if errors.Is(err, auth.ErrInvalidCredentials) { + s.loginByEmail.Fail(emailKey) + s.loginByAddr.Fail(addr) + // The reason is the whole value of this line and must never leave it. + s.log.Warn("login failed", "email", email, "addr", addr, "reason", string(reason)) + writeError(w, s.log, domain.Unauthenticated()) + return + } + if err != nil { + // The database is down, or a stored hash is unreadable. The caller's + // credentials were never judged, so this is a 500 and not a 401. + writeError(w, s.log, domain.Internal(err)) + return + } + + token, sess, err := s.sessions.Issue(r.Context(), user.ID, req.RememberMe) + if err != nil { + writeError(w, s.log, domain.Internal(err)) + return + } + + // A correct password clears the email's penalty, so two typos followed by a + // success leave nothing behind. The address counter is left alone: one + // correct login should not wipe the budget for every other account being + // tried from the same host. + s.loginByEmail.Reset(emailKey) + + s.setSessionCookie(w, token, time.Until(sess.ExpiresAt)) + + // Best effort, deliberately after the session exists: a failure to stamp + // last_login_at is a lost diagnostic, not a reason to refuse a sign-in that + // has already succeeded. + if err := s.users.MarkLoggedIn(r.Context(), user.ID, s.now()); err != nil { + s.log.Warn("could not record last_login_at", "user_id", user.ID, "error", err) + } + + s.log.Info("login", "user_id", user.ID, "email", user.Email, + "remember_me", req.RememberMe, "session_id", sess.ID, + "expires_at", sess.ExpiresAt, "absolute_expires_at", sess.AbsoluteExpiresAt) + + // The body is the user, in exactly the shape GET /me returns, so the + // frontend can render the signed-in state without a second round trip. + // + // The token is NOT here and must never be. It went out in a Set-Cookie + // header the page cannot read; putting it in the body would hand it to + // every script on the page and undo HttpOnly entirely. + record, err := s.userRecord(r.Context(), s.db.Pool, user.ID) + if err != nil { + writeError(w, s.log, err) + return + } + writeRecord(w, http.StatusOK, record) +} + +// handleLogout revokes the session behind the cookie and clears the cookie. +// +// Idempotent by construction: no cookie, an unknown token and a live session +// all end the same way — the cookie is cleared and the answer is 200. Logging +// out is a request to not be signed in, and the caller is not signed in +// afterwards in every one of those cases. +// +// It is deliberately public. Requiring a valid session to log out means a user +// whose session has already expired gets a 401 from the one action that would +// have tidied up their stale cookie. +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + if token := sessionToken(r); token != "" { + if err := s.sessions.Revoke(r.Context(), token); err != nil { + // Revoke already treats "no such session" as success, so this is a + // real failure — the database, most likely. Clearing the cookie is + // still the right thing to do, and reporting a 500 for a logout + // would leave the caller signed in with no way to fix it. + s.log.Error("could not revoke session on logout", "error", err) + } + } + s.clearSessionCookie(w) + writeJSON(w, http.StatusOK, envelope{Data: map[string]any{"status": "signed_out"}}) +} + +/* ── Middleware ─────────────────────────────────────────────────────────── */ + +// publicPaths are the only endpoints reachable without a session. +// +// An allowlist rather than a list of protected prefixes, so the failure mode of +// forgetting to update it is a route that refuses everyone — not one that +// serves everyone. A new endpoint is private until someone deliberately says +// otherwise, which is the direction a mistake should fall in. +var publicPaths = map[string]bool{ + "/health": true, + "/api/v1/auth/login": true, + "/api/v1/auth/logout": true, +} + +// authenticate resolves the session cookie into an identity, or refuses. +// +// This replaces devOrgMiddleware, which put a fixed organization on every +// request with no credential behind it. The seam is the same one that comment +// promised: everything downstream still reads the organization from +// orgctx, and not one service or repository changed. +// +// What the request cannot influence: nothing here reads the body, the query +// string or any header other than Cookie. The user id, the organization and the +// role are all read from the sessions and users tables, keyed by a token the +// client cannot forge without already holding it. +func (s *Server) authenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if publicPaths[r.URL.Path] { + next.ServeHTTP(w, r) + return + } + + token := sessionToken(r) + if token == "" { + writeError(w, s.log, domain.Unauthenticated()) + return + } + + sess, err := s.sessions.Authenticate(r.Context(), token) + if err != nil { + // Not found and expired are logged apart and answered identically. + // Clearing the cookie stops the browser re-sending a token that + // will never work again. + s.log.Debug("session rejected", "reason", sessionRejection(err), "path", r.URL.Path) + if errors.Is(err, auth.ErrSessionNotFound) || errors.Is(err, auth.ErrSessionExpired) || + errors.Is(err, auth.ErrEmptyToken) { + s.clearSessionCookie(w) + writeError(w, s.log, domain.Unauthenticated()) + return + } + writeError(w, s.log, domain.Internal(err)) + return + } + + // The user is re-read on every request rather than cached in the + // session row, so suspending an account takes effect on the account's + // next request instead of whenever its session happens to lapse. + user, err := s.users.FindByID(r.Context(), sess.UserID) + if err != nil { + if errors.Is(err, auth.ErrUserNotFound) { + // The FK cascades, so this should be unreachable. If it happens + // the session is orphaned and worth destroying. + s.log.Warn("session references a missing user", "session_id", sess.ID) + _ = s.sessions.RevokeID(r.Context(), sess.ID) + s.clearSessionCookie(w) + writeError(w, s.log, domain.Unauthenticated()) + return + } + writeError(w, s.log, domain.Internal(err)) + return + } + if !user.IsActive() { + // Suspension revokes on contact. Leaving the session alive would + // mean a suspended account keeps a working cookie for up to thirty + // days, refused one request at a time. + s.log.Warn("session for an inactive user revoked", + "user_id", user.ID, "status", user.Status) + _ = s.sessions.RevokeID(r.Context(), sess.ID) + s.clearSessionCookie(w) + writeError(w, s.log, domain.Unauthenticated()) + return + } + + id := authctx.Identity{ + UserID: user.ID, OrgID: user.OrgID, Email: user.Email, + FullName: user.FullName, Role: user.Role, AccountType: user.AccountType, + Status: user.Status, SessionID: sess.ID, ExpiresAt: sess.ExpiresAt, + } + ctx := authctx.With(r.Context(), id) + // The organization comes from the user's row, never from the request. + // Every service and repository already takes it as a parameter, so this + // one line is the whole of the tenancy change. + ctx = orgctx.With(ctx, user.OrgID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// sessionRejection names why a session was refused, for the log only. +func sessionRejection(err error) string { + switch { + case errors.Is(err, auth.ErrSessionNotFound): + return "not_found" + case errors.Is(err, auth.ErrSessionExpired): + return "expired" + case errors.Is(err, auth.ErrEmptyToken): + return "empty_token" + default: + return "error" + } +} + +// retryAfterSeconds renders a duration for the Retry-After header, rounded up +// and never below one second — "Retry-After: 0" invites an immediate retry. +func retryAfterSeconds(d time.Duration) string { + secs := int(d.Round(time.Second) / time.Second) + if secs < 1 { + secs = 1 + } + return strconv.Itoa(secs) +} diff --git a/go-api/internal/httpserver/auth_test.go b/go-api/internal/httpserver/auth_test.go new file mode 100644 index 0000000..e524aa5 --- /dev/null +++ b/go-api/internal/httpserver/auth_test.go @@ -0,0 +1,926 @@ +package httpserver_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/krow/krow-backend/go-api/internal/auth" + "github.com/krow/krow-backend/go-api/internal/httpserver" + "github.com/krow/krow-backend/go-api/internal/testutil" +) + +// shortSessions keeps the expiry arithmetic in these tests small. The real +// lifetimes are asserted against the production policy in +// TestSessionLifetimes, which is the test that would catch a change to them. +var shortSessions = auth.Policy{ + IdleLifetime: time.Hour, + AbsoluteLifetime: 3 * time.Hour, + RememberIdleLifetime: 24 * time.Hour, + RememberAbsoluteLifetime: 72 * time.Hour, +} + +// clockedAPI is newAPI with a clock the test drives, so expiry can be reached +// without sleeping. +func clockedAPI(t *testing.T, opts ...httpserver.Option) (*api, *time.Time) { + t.Helper() + now := time.Date(2026, 8, 22, 9, 0, 0, 0, time.UTC) + all := append([]httpserver.Option{ + httpserver.WithClock(func() time.Time { return now }), + httpserver.WithSessionPolicy(shortSessions), + }, opts...) + return newAPI(t, all...), &now +} + +/* ── 1-5. Login and its failure modes ───────────────────────────────────── */ + +// 1. A correct email and password sign in and return the user. +func TestLoginSucceeds(t *testing.T) { + a := newAPI(t) + + result := signIn(t, a.handler, a.email, harnessPassword, false) + if result.code != http.StatusOK { + t.Fatalf("login = %d, want 200 (%v)", result.code, result.body) + } + + data, ok := result.body["data"].(map[string]any) + if !ok { + t.Fatalf("login body has no data object: %v", result.body) + } + if data["id"] != a.userID { + t.Errorf("login returned user %v, want %v", data["id"], a.userID) + } + if data["email"] != a.email { + t.Errorf("login returned email %v, want %v", data["email"], a.email) + } + // The frontend renders the signed-in state straight from this body, so the + // embedded preferences must be there as they are on GET /me. + if _, ok := data["preferences"].(map[string]any); !ok { + t.Error("login response has no embedded preferences") + } + + // The email is case-insensitive: users type their address how they like. + upper := signIn(t, a.handler, strings.ToUpper(a.email), harnessPassword, false) + if upper.code != http.StatusOK { + t.Errorf("login with an upper-case email = %d, want 200", upper.code) + } + + // last_login_at is stamped. + var lastLogin *time.Time + if err := a.h.Pool.QueryRow(context.Background(), + `SELECT last_login_at FROM users WHERE id = $1::uuid`, a.userID).Scan(&lastLogin); err != nil { + t.Fatalf("read last_login_at: %v", err) + } + if lastLogin == nil { + t.Error("a successful login did not record last_login_at") + } +} + +// 2, 3, 4, 5. Every credential failure is externally identical. +// +// This is one test rather than four because the property under test is the +// sameness: a wrong password, an unknown address, an account with no password +// and a suspended account must be indistinguishable from outside. Asserting +// each in isolation would not catch the one thing that matters. +func TestLoginFailuresAreIndistinguishable(t *testing.T) { + a := newAPI(t) + + // A second account, suspended, with a valid password set. + suspended := newUser(t, a.h.Pool, a.orgID, "suspended@example.test", "employer") + setStatus(t, a.h.Pool, suspended, "suspended") + + // A third with no password at all — the state every seeded user starts in. + passwordless := "nopassword@example.test" + if _, err := a.h.Pool.Exec(context.Background(), + `INSERT INTO users (org_id, email, full_name) VALUES ($1::uuid, $2::citext, 'No Password')`, + a.orgID, passwordless); err != nil { + t.Fatalf("create the passwordless user: %v", err) + } + + cases := map[string]struct{ email, password string }{ + "wrong password": {a.email, "definitely-not-the-password"}, + "unknown email": {"nobody@example.invalid", harnessPassword}, + "suspended user": {"suspended@example.test", harnessPassword}, + "no password set": {passwordless, harnessPassword}, + "empty-ish password": {a.email, "x"}, + } + + var bodies []string + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + result := signIn(t, a.handler, tc.email, tc.password, false) + if result.code != http.StatusUnauthorized { + t.Fatalf("login = %d, want 401", result.code) + } + if result.cookie != nil && result.cookie.Value != "" { + t.Error("a failed login set a session cookie") + } + bodies = append(bodies, result.raw.Body.String()) + + // The message must not name the reason. + body := strings.ToLower(result.raw.Body.String()) + for _, leak := range []string{ + "password", "suspend", "inactive", "not found", "no such", + "unknown", "exist", "email address is not", + } { + if strings.Contains(body, leak) { + t.Errorf("the login error mentions %q, which distinguishes the failure:\n%s", + leak, result.raw.Body.String()) + } + } + }) + } + + // 5. Byte-for-byte identical, not merely "all 401". + for i := 1; i < len(bodies); i++ { + if bodies[i] != bodies[0] { + t.Errorf("login failures differ:\n%s\nvs\n%s", bodies[0], bodies[i]) + } + } + + // A suspended user with the RIGHT password is still refused. Worth its own + // assertion: this is the check that must come after the password test, or + // the timing of the refusal leaks that the account exists. + if got := signIn(t, a.handler, "suspended@example.test", harnessPassword, false); got.code != http.StatusUnauthorized { + t.Errorf("a suspended user with a correct password got %d, want 401", got.code) + } +} + +// A malformed request is a 422 about the request, not a 401 about an account. +// Saying "you did not send a password" reveals nothing about any user. +func TestLoginRejectsMalformedRequests(t *testing.T) { + a := newAPI(t) + + for name, payload := range map[string]any{ + "no email": map[string]any{"password": harnessPassword}, + "no password": map[string]any{"email": a.email}, + "both blank": map[string]any{"email": " ", "password": ""}, + } { + t.Run(name, func(t *testing.T) { + r := a.doAnon("POST", "/api/v1/auth/login", payload) + if r.code != http.StatusUnprocessableEntity { + t.Errorf("login = %d, want 422", r.code) + } + }) + } +} + +/* ── 6-9. The session cookie ────────────────────────────────────────────── */ + +// 6, 7. The cookie is created with the right attributes, and the token appears +// nowhere a script could read it. +func TestLoginSetsHardenedCookieAndNeverReturnsTheToken(t *testing.T) { + a := newAPI(t) + result := signIn(t, a.handler, a.email, harnessPassword, false) + + c := result.cookie + if c == nil { + t.Fatal("login set no session cookie") + } + if c.Value == "" { + t.Fatal("the session cookie is empty") + } + if !c.HttpOnly { + t.Error("the session cookie is not HttpOnly: a script on the page could read it") + } + if c.SameSite != http.SameSiteLaxMode { + t.Errorf("SameSite = %v, want Lax", c.SameSite) + } + if c.Path != "/" { + t.Errorf("Path = %q, want /", c.Path) + } + // APP_ENV=development in this harness, and localhost is plain HTTP: a + // Secure cookie would never be sent back. TestCookieIsSecureOutsideDevelopment + // covers the other half. + if c.Secure { + t.Error("the cookie is Secure in development; the browser would never return it over HTTP") + } + + // 7. The raw token is in the Set-Cookie header and nowhere else. + if strings.Contains(result.raw.Body.String(), c.Value) { + t.Error("the login response body contains the session token") + } + // And the same for every other response the API gives while signed in. + me := a.doWith(c, "GET", "/api/v1/me") + encoded, _ := json.Marshal(me.body) + if strings.Contains(string(encoded), c.Value) { + t.Error("GET /me leaks the session token") + } + + // 11 (verification list). The database holds the hash, never the token. + var rawRows, hashRows int + if err := a.h.Pool.QueryRow(context.Background(), + `SELECT count(*)::int FROM sessions WHERE token_hash = $1::text`, c.Value).Scan(&rawRows); err != nil { + t.Fatalf("scan for a stored raw token: %v", err) + } + if rawRows != 0 { + t.Error("the raw session token is stored in PostgreSQL") + } + if err := a.h.Pool.QueryRow(context.Background(), + `SELECT count(*)::int FROM sessions WHERE token_hash = $1::text`, + auth.HashToken(c.Value)).Scan(&hashRows); err != nil { + t.Fatalf("scan for the stored hash: %v", err) + } + if hashRows != 1 { + t.Errorf("%d session rows hold the token's hash, want 1", hashRows) + } +} + +// Outside development the cookie must be Secure, or it can be read off the wire. +func TestCookieIsSecureOutsideDevelopment(t *testing.T) { + h := testutil.New(t) + srv := newServer(t, h, nil) + userID, email := seededUser(t, h.Pool) + setPassword(t, h.Pool, userID) + + // A production-shaped server over the same database. + prod := newServerWithEnv(t, h, "production") + if got := signIn(t, prod, email, harnessPassword, false); got.cookie == nil || !got.cookie.Secure { + t.Errorf("the cookie is not Secure when APP_ENV=production: %+v", got.cookie) + } + // The development server, for contrast, on the same database. + if got := signIn(t, srv.Handler(), email, harnessPassword, false); got.cookie == nil || got.cookie.Secure { + t.Error("the cookie is Secure in development") + } +} + +// 8, 9. Normal and Remember Me sessions get the lifetimes the decision names, +// in the cookie and in the row. +func TestSessionLifetimes(t *testing.T) { + a := newAPI(t) // the production policy: 12h / 24h and 30d / 90d + + for name, tc := range map[string]struct { + remember bool + wantIdle time.Duration + wantAbsolute time.Duration + }{ + "normal": {false, 12 * time.Hour, 24 * time.Hour}, + "remember me": {true, 30 * 24 * time.Hour, 90 * 24 * time.Hour}, + } { + t.Run(name, func(t *testing.T) { + before := time.Now() + result := signIn(t, a.handler, a.email, harnessPassword, tc.remember) + if result.code != http.StatusOK || result.cookie == nil { + t.Fatalf("login = %d", result.code) + } + + // The cookie's own lifetime matches the session's. + wantMaxAge := int(tc.wantIdle.Seconds()) + if drift := result.cookie.MaxAge - wantMaxAge; drift > 5 || drift < -5 { + t.Errorf("cookie Max-Age = %d, want about %d", result.cookie.MaxAge, wantMaxAge) + } + + // And so does the row, which is the authority. + var expires, absolute time.Time + if err := a.h.Pool.QueryRow(context.Background(), + `SELECT expires_at, absolute_expires_at FROM sessions WHERE token_hash = $1::text`, + auth.HashToken(result.cookie.Value)).Scan(&expires, &absolute); err != nil { + t.Fatalf("read the session row: %v", err) + } + assertAbout(t, "expires_at", expires.Sub(before), tc.wantIdle) + assertAbout(t, "absolute_expires_at", absolute.Sub(before), tc.wantAbsolute) + if absolute.Before(expires) { + t.Error("the absolute deadline is before the sliding one") + } + }) + } +} + +func assertAbout(t *testing.T, name string, got, want time.Duration) { + t.Helper() + if drift := got - want; drift > time.Minute || drift < -time.Minute { + t.Errorf("%s is %v from now, want about %v", name, got, want) + } +} + +/* ── 10-11. Logout ──────────────────────────────────────────────────────── */ + +// 10, 11. Logging out revokes the session, clears the cookie, and is idempotent. +func TestLogout(t *testing.T) { + a := newAPI(t) + + // Signed in, the protected endpoint works. + if got := a.do("GET", "/api/v1/me", nil); got.code != http.StatusOK { + t.Fatalf("GET /me before logout = %d, want 200", got.code) + } + + req := httptest.NewRequest("POST", "/api/v1/auth/logout", nil) + req.AddCookie(a.cookie) + rec := httptest.NewRecorder() + a.handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("logout = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + // The cookie is expired in the browser. + var cleared *http.Cookie + for _, c := range rec.Result().Cookies() { + if c.Name == sessionCookie { + cleared = c + } + } + if cleared == nil { + t.Fatal("logout did not clear the session cookie") + } + if cleared.MaxAge >= 0 || cleared.Value != "" { + t.Errorf("the cleared cookie is %+v, want an empty value and a negative Max-Age", cleared) + } + // The attributes must match the ones it was set with, or the browser keeps + // the original alongside this one. + if cleared.Path != "/" || !cleared.HttpOnly { + t.Errorf("the cleared cookie has different attributes: %+v", cleared) + } + + // The row is gone, not merely expired. + var rows int + if err := a.h.Pool.QueryRow(context.Background(), + `SELECT count(*)::int FROM sessions WHERE token_hash = $1::text`, + auth.HashToken(a.cookie.Value)).Scan(&rows); err != nil { + t.Fatalf("count sessions: %v", err) + } + if rows != 0 { + t.Error("logout left the session row in the database") + } + + // The old cookie no longer authenticates anything. + if got := a.do("GET", "/api/v1/me", nil); got.code != http.StatusUnauthorized { + t.Errorf("GET /me after logout = %d, want 401", got.code) + } +} + +// 11. Every shape of logout succeeds: twice over, with a stale cookie, and with +// no cookie at all. A user asking not to be signed in is not signed in +// afterwards in all three cases, so all three are successes. +func TestLogoutIsIdempotent(t *testing.T) { + a := newAPI(t) + stale := a.cookie + + for i, attempt := range []string{"first", "second", "third"} { + req := httptest.NewRequest("POST", "/api/v1/auth/logout", nil) + req.AddCookie(stale) + rec := httptest.NewRecorder() + a.handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("%s logout (attempt %d) = %d, want 200", attempt, i+1, rec.Code) + } + } + + // With no cookie at all — a signed-out browser clicking sign out. + if got := a.doAnon("POST", "/api/v1/auth/logout", nil); got.code != http.StatusOK { + t.Errorf("logout with no cookie = %d, want 200", got.code) + } + // And with a token that was never real. + junk := &http.Cookie{Name: sessionCookie, Value: "not-a-real-token"} + if got := a.doWith(junk, "POST", "/api/v1/auth/logout"); got.code != http.StatusOK { + t.Errorf("logout with a junk cookie = %d, want 200", got.code) + } +} + +/* ── 12-15, 20. The middleware ──────────────────────────────────────────── */ + +// 20. Without a session, protected endpoints refuse. +func TestUnauthenticatedRequestsAreRefused(t *testing.T) { + a := newAPI(t) + + for _, path := range []string{ + "/api/v1/me", + "/api/v1/me/preferences", + "/api/v1/job-postings", + "/api/v1/job-applications", + "/api/v1/worker-profiles", + "/api/v1/courses", + "/api/v1/staff", + } { + got := a.doAnon("GET", path, nil) + if got.code != http.StatusUnauthorized { + t.Errorf("GET %s without a session = %d, want 401", path, got.code) + } + if body, _ := got.body["error"].(map[string]any); body == nil || body["code"] != "unauthorized" { + t.Errorf("GET %s: error code = %v, want unauthorized", path, got.body) + } + } + + // Writes too, not only reads. + if got := a.doAnon("POST", "/api/v1/job-postings", map[string]any{"title": "x"}); got.code != http.StatusUnauthorized { + t.Errorf("POST without a session = %d, want 401", got.code) + } + if got := a.doAnon("PATCH", "/api/v1/me", map[string]any{"full_name": "x"}); got.code != http.StatusUnauthorized { + t.Errorf("PATCH /me without a session = %d, want 401", got.code) + } + + // The public three stay public. + if got := a.doAnon("GET", "/health", nil); got.code != http.StatusOK { + t.Errorf("GET /health without a session = %d, want 200", got.code) + } + if got := a.doAnon("POST", "/api/v1/auth/logout", nil); got.code != http.StatusOK { + t.Errorf("POST /auth/logout without a session = %d, want 200", got.code) + } + if got := a.doAnon("POST", "/api/v1/auth/login", map[string]any{ + "email": a.email, "password": harnessPassword, + }); got.code != http.StatusOK { + t.Errorf("POST /auth/login without a session = %d, want 200", got.code) + } +} + +// 12. A token that does not name a session is refused, whatever it looks like. +func TestInvalidSessionsAreRejected(t *testing.T) { + a := newAPI(t) + + unknown, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + for name, value := range map[string]string{ + "well-formed but unknown": unknown, + "junk": "not-a-token-at-all", + "empty": "", + "the stored hash": auth.HashToken(a.cookie.Value), + "the token, altered": a.cookie.Value[:len(a.cookie.Value)-1] + "X", + } { + t.Run(name, func(t *testing.T) { + got := a.doWith(&http.Cookie{Name: sessionCookie, Value: value}, "GET", "/api/v1/me") + if got.code != http.StatusUnauthorized { + t.Errorf("GET /me with a %s token = %d, want 401", name, got.code) + } + }) + } + + // Presenting the *hash* must not work. It is the value in the database, so + // a lookup that forgot to hash the cookie would accept it — and a leaked + // database dump would then be a set of working credentials. + got := a.doWith(&http.Cookie{Name: sessionCookie, Value: auth.HashToken(a.cookie.Value)}, "GET", "/api/v1/me") + if got.code == http.StatusOK { + t.Fatal("the stored token hash authenticated as a token") + } +} + +// 13. An expired session is refused, and the row is cleaned up as it is found. +func TestExpiredSessionIsRejected(t *testing.T) { + a, now := clockedAPI(t) + + if got := a.do("GET", "/api/v1/me", nil); got.code != http.StatusOK { + t.Fatalf("GET /me while live = %d, want 200", got.code) + } + + // Past the idle deadline without using it. + *now = now.Add(shortSessions.IdleLifetime + time.Minute) + if got := a.do("GET", "/api/v1/me", nil); got.code != http.StatusUnauthorized { + t.Fatalf("GET /me after expiry = %d, want 401", got.code) + } + + var rows int + if err := a.h.Pool.QueryRow(context.Background(), + `SELECT count(*)::int FROM sessions WHERE token_hash = $1::text`, + auth.HashToken(a.cookie.Value)).Scan(&rows); err != nil { + t.Fatalf("count sessions: %v", err) + } + if rows != 0 { + t.Error("an expired session was refused but left in the database") + } +} + +// A session used steadily slides forward and keeps working — but never past its +// absolute ceiling. +func TestSessionSlidesButNotForever(t *testing.T) { + a, now := clockedAPI(t) + start := *now + + for _, at := range []time.Duration{50 * time.Minute, 105 * time.Minute, 160 * time.Minute} { + *now = start.Add(at) + if got := a.do("GET", "/api/v1/me", nil); got.code != http.StatusOK { + t.Fatalf("GET /me at +%v = %d, want 200 — the session should have slid", at, got.code) + } + } + + *now = start.Add(shortSessions.AbsoluteLifetime) + if got := a.do("GET", "/api/v1/me", nil); got.code != http.StatusUnauthorized { + t.Errorf("GET /me at the absolute ceiling = %d, want 401", got.code) + } +} + +// 14. The identity is the session's user, not the first or the oldest one. +func TestAuthenticatedRequestResolvesTheSessionUser(t *testing.T) { + a := newAPI(t) + + other := newUser(t, a.h.Pool, a.orgID, "second-user@example.test", "employer") + result := signIn(t, a.handler, "second-user@example.test", harnessPassword, false) + if result.code != http.StatusOK { + t.Fatalf("second user login = %d", result.code) + } + + got := a.doWith(result.cookie, "GET", "/api/v1/me") + if got.code != http.StatusOK { + t.Fatalf("GET /me = %d", got.code) + } + data := got.body["data"].(map[string]any) + if data["id"] != other { + t.Errorf("GET /me returned %v, want the second user %v", data["id"], other) + } + if data["email"] != "second-user@example.test" { + t.Errorf("GET /me returned email %v", data["email"]) + } + + // The seeded user's own cookie still resolves to the seeded user: two + // sessions, two identities, no crosstalk. + first := a.do("GET", "/api/v1/me", nil) + if first.body["data"].(map[string]any)["id"] != a.userID { + t.Error("the first session no longer resolves to its own user") + } +} + +// 15. Suspending an account takes effect on its next request, and takes the +// session with it. +func TestSuspendedUserIsRejectedMidSession(t *testing.T) { + a := newAPI(t) + + victim := newUser(t, a.h.Pool, a.orgID, "about-to-be-suspended@example.test", "employer") + result := signIn(t, a.handler, "about-to-be-suspended@example.test", harnessPassword, false) + if result.code != http.StatusOK { + t.Fatalf("login = %d", result.code) + } + if got := a.doWith(result.cookie, "GET", "/api/v1/me"); got.code != http.StatusOK { + t.Fatalf("GET /me while active = %d, want 200", got.code) + } + + setStatus(t, a.h.Pool, victim, "suspended") + + if got := a.doWith(result.cookie, "GET", "/api/v1/me"); got.code != http.StatusUnauthorized { + t.Fatalf("GET /me after suspension = %d, want 401", got.code) + } + // The session is destroyed rather than refused one request at a time: a + // suspended account must not keep a working cookie for thirty days. + var rows int + if err := a.h.Pool.QueryRow(context.Background(), + `SELECT count(*)::int FROM sessions WHERE user_id = $1::uuid`, victim).Scan(&rows); err != nil { + t.Fatalf("count sessions: %v", err) + } + if rows != 0 { + t.Errorf("%d sessions survive for a suspended user, want 0", rows) + } +} + +/* ── 16-19. /me ─────────────────────────────────────────────────────────── */ + +// 16. GET /me is the session's user. +func TestMeReturnsTheSessionUser(t *testing.T) { + a := newAPI(t) + + got := a.do("GET", "/api/v1/me", nil) + if got.code != http.StatusOK { + t.Fatalf("GET /me = %d", got.code) + } + data := got.body["data"].(map[string]any) + if data["id"] != a.userID { + t.Errorf("GET /me id = %v, want %v", data["id"], a.userID) + } + // The frontend contract: these fields must still be here. + for _, field := range []string{"id", "email", "full_name", "role", "account_type", "status", "preferences"} { + if _, ok := data[field]; !ok { + t.Errorf("GET /me no longer returns %q", field) + } + } + // And these must not be. + for _, field := range []string{"password_hash", "org_id"} { + if _, ok := data[field]; ok { + t.Errorf("GET /me exposes %q", field) + } + } +} + +// 17, 18, 19. A user cannot edit what the server owns about them. +func TestMeCannotEditServerOwnedFields(t *testing.T) { + a := newAPI(t) + + before := readUserRow(t, a) + + // Everything at once, plus each on its own below, because a handler could + // plausibly filter one and not another. + got := a.do("PATCH", "/api/v1/me", map[string]any{ + "role": "admin", + "org_id": "00000000-0000-0000-0000-000000000000", + "password_hash": "$argon2id$v=19$m=65536,t=3,p=4$YWFhYWFhYWFhYWFhYWFhYQ$" + strings.Repeat("A", 43), + "id": "00000000-0000-0000-0000-000000000000", + "status": "suspended", + "email": "attacker@example.invalid", + "full_name": "A Legitimate Rename", + }) + if got.code != http.StatusOK { + t.Fatalf("PATCH /me = %d (%v)", got.code, got.body) + } + + after := readUserRow(t, a) + if after.role != before.role { + t.Errorf("role changed from %q to %q — privilege escalation", before.role, after.role) + } + if after.orgID != before.orgID { + t.Errorf("org_id changed from %q to %q — tenancy escape", before.orgID, after.orgID) + } + if after.passwordHash != before.passwordHash { + t.Error("password_hash was overwritten through PATCH /me") + } + if after.id != before.id { + t.Errorf("id changed from %q to %q", before.id, after.id) + } + if after.status != before.status { + t.Errorf("status changed from %q to %q", before.status, after.status) + } + if after.email != before.email { + t.Errorf("email changed from %q to %q", before.email, after.email) + } + // The one legitimate field in that payload did land, so the endpoint is + // filtering rather than refusing everything. + if after.fullName != "A Legitimate Rename" { + t.Errorf("full_name = %q, want the rename to have applied", after.fullName) + } + // The response reports the truth rather than echoing the request. + if data := got.body["data"].(map[string]any); data["role"] != before.role { + t.Errorf("the response reports role %v, want the unchanged %q", data["role"], before.role) + } + + // One at a time. + for field, value := range map[string]any{ + "role": "admin", + "org_id": "00000000-0000-0000-0000-000000000000", + "password_hash": "anything", + "status": "suspended", + } { + t.Run(field, func(t *testing.T) { + r := a.do("PATCH", "/api/v1/me", map[string]any{field: value}) + if r.code != http.StatusOK { + t.Fatalf("PATCH /me {%s} = %d", field, r.code) + } + now := readUserRow(t, a) + if now.role != before.role || now.orgID != before.orgID || + now.passwordHash != before.passwordHash || now.status != before.status { + t.Errorf("PATCH /me {%s: %v} changed a server-owned field", field, value) + } + }) + } + + // account_type stays editable: it is a display attribute, not authorization, + // and Layout.jsx writes it when the viewer switches surface. + if r := a.do("PATCH", "/api/v1/me", map[string]any{"account_type": "talent"}); r.code != http.StatusOK { + t.Fatalf("PATCH /me {account_type} = %d", r.code) + } + if readUserRow(t, a).accountType != "talent" { + t.Error("account_type is no longer self-editable; Layout.jsx's role switch depends on it") + } +} + +type userRow struct { + id, orgID, email, fullName, role, accountType, status, passwordHash string +} + +func readUserRow(t *testing.T, a *api) userRow { + t.Helper() + var u userRow + if err := a.h.Pool.QueryRow(context.Background(), + `SELECT id::text, org_id::text, email::text, full_name, role, account_type, status, + COALESCE(password_hash, '') FROM users WHERE id = $1::uuid`, a.userID). + Scan(&u.id, &u.orgID, &u.email, &u.fullName, &u.role, &u.accountType, &u.status, &u.passwordHash); err != nil { + t.Fatalf("read the user row: %v", err) + } + return u +} + +/* ── Identity comes from the session, never from the request ────────────── */ + +// The security property the whole phase exists for: nothing a client writes can +// change who it is. +func TestIdentityCannotBeSuppliedByTheRequest(t *testing.T) { + a := newAPI(t) + + victim := newUser(t, a.h.Pool, a.orgID, "victim@example.test", "admin") + + // A body naming another user. + got := a.do("PATCH", "/api/v1/me", map[string]any{ + "id": victim, "user_id": victim, "full_name": "Renamed By An Impostor", + }) + if got.code != http.StatusOK { + t.Fatalf("PATCH /me = %d", got.code) + } + if got.body["data"].(map[string]any)["id"] != a.userID { + t.Error("a user id in the body changed whose record was returned") + } + var victimName string + if err := a.h.Pool.QueryRow(context.Background(), + `SELECT full_name FROM users WHERE id = $1::uuid`, victim).Scan(&victimName); err != nil { + t.Fatalf("read the victim: %v", err) + } + if victimName == "Renamed By An Impostor" { + t.Fatal("a user id in the request body redirected the write to another user") + } + + // A query string naming another user, and another organization. + for _, q := range []string{ + "?user_id=" + victim, + "?org_id=00000000-0000-0000-0000-000000000000", + "?id=" + victim, + } { + r := a.do("GET", "/api/v1/me"+q, nil) + if r.code != http.StatusOK { + t.Fatalf("GET /me%s = %d", q, r.code) + } + if r.body["data"].(map[string]any)["id"] != a.userID { + t.Errorf("GET /me%s resolved to a different user", q) + } + } +} + +// The organization is read from the user's row, so a session in one tenant sees +// no data from another. This is what replaced the fixed development org. +func TestTenancyFollowsTheSession(t *testing.T) { + a := newAPI(t) + + // The seeded organization has data. + mine := a.do("GET", "/api/v1/job-postings?limit=100", nil) + if mine.code != http.StatusOK { + t.Fatalf("GET /job-postings = %d", mine.code) + } + if len(mine.records(t)) == 0 { + t.Fatal("the seeded organization has no job postings; the test proves nothing") + } + + // A second organization, with a user of its own and no data. + var otherOrg string + if err := a.h.Pool.QueryRow(context.Background(), + `INSERT INTO organizations (name, slug) VALUES ('Other Tenant', 'other-tenant') RETURNING id::text`). + Scan(&otherOrg); err != nil { + t.Fatalf("create the second organization: %v", err) + } + newUser(t, a.h.Pool, otherOrg, "outsider@example.test", "admin") + + result := signIn(t, a.handler, "outsider@example.test", harnessPassword, false) + if result.code != http.StatusOK { + t.Fatalf("outsider login = %d", result.code) + } + theirs := a.doWith(result.cookie, "GET", "/api/v1/job-postings?limit=100") + if theirs.code != http.StatusOK { + t.Fatalf("GET /job-postings as the outsider = %d", theirs.code) + } + if n := len(theirs.records(t)); n != 0 { + t.Errorf("a user in another organization sees %d job postings, want 0", n) + } +} + +/* ── 21. Sweeping ───────────────────────────────────────────────────────── */ + +// 21. The sweep collects sessions nobody comes back for, and leaves live ones. +func TestSessionSweep(t *testing.T) { + a, now := clockedAPI(t) + ctx := context.Background() + + // a.cookie is a normal session (1h idle here). Add a Remember Me one. + long := signIn(t, a.handler, a.email, harnessPassword, true) + if long.code != http.StatusOK { + t.Fatalf("remember-me login = %d", long.code) + } + if n := countSessions(t, a); n != 2 { + t.Fatalf("%d sessions before the sweep, want 2", n) + } + + // Nothing is due yet. + if deleted, err := a.srv.Sessions().Sweep(ctx); err != nil || deleted != 0 { + t.Errorf("early sweep deleted %d (err %v), want 0", deleted, err) + } + + // Past the short session's deadline, not the long one's. + *now = now.Add(shortSessions.IdleLifetime + time.Minute) + deleted, err := a.srv.Sessions().Sweep(ctx) + if err != nil { + t.Fatalf("Sweep: %v", err) + } + if deleted != 1 { + t.Errorf("sweep deleted %d sessions, want 1", deleted) + } + if n := countSessions(t, a); n != 1 { + t.Errorf("%d sessions remain, want 1", n) + } + // The survivor still works. + if got := a.doWith(long.cookie, "GET", "/api/v1/me"); got.code != http.StatusOK { + t.Errorf("the swept database rejected a live session: %d", got.code) + } + + // Past everything. + *now = now.Add(shortSessions.RememberAbsoluteLifetime) + if _, err := a.srv.Sessions().Sweep(ctx); err != nil { + t.Fatalf("Sweep: %v", err) + } + if n := countSessions(t, a); n != 0 { + t.Errorf("%d sessions remain after everything expired, want 0", n) + } +} + +func countSessions(t *testing.T, a *api) int { + t.Helper() + var n int + if err := a.h.Pool.QueryRow(context.Background(), `SELECT count(*)::int FROM sessions`).Scan(&n); err != nil { + t.Fatalf("count sessions: %v", err) + } + return n +} + +/* ── 22. Rate limiting ──────────────────────────────────────────────────── */ + +// 22. Repeated failures are refused, before any password is checked. +func TestLoginRateLimit(t *testing.T) { + a, now := clockedAPI(t, httpserver.WithLoginRateLimit(3, 50, 10*time.Minute)) + start := *now + + // Three failures use the budget. + for i := 1; i <= 3; i++ { + if got := signIn(t, a.handler, a.email, "wrong-password", false); got.code != http.StatusUnauthorized { + t.Fatalf("failure %d = %d, want 401", i, got.code) + } + } + + // The fourth is refused as rate limited, not as a bad password. + blocked := signIn(t, a.handler, a.email, "wrong-password", false) + if blocked.code != http.StatusTooManyRequests { + t.Fatalf("the fourth attempt = %d, want 429", blocked.code) + } + if got := blocked.raw.Header().Get("Retry-After"); got == "" || got == "0" { + t.Errorf("Retry-After = %q, want a positive number of seconds", got) + } + if body, _ := blocked.body["error"].(map[string]any); body == nil || body["code"] != "rate_limited" { + t.Errorf("error code = %v, want rate_limited", blocked.body) + } + + // The CORRECT password is refused too. The limit is checked before the + // credentials, which is the point: an attacker must not be able to make the + // server hash for them, and must not learn from a 401-vs-429 difference + // whether their guess was right. + correct := signIn(t, a.handler, a.email, harnessPassword, false) + if correct.code != http.StatusTooManyRequests { + t.Errorf("a correct password during a block = %d, want 429", correct.code) + } + if correct.cookie != nil { + t.Error("a rate-limited login still issued a session") + } + + // The window passes and the budget returns. + *now = start.Add(11 * time.Minute) + if got := signIn(t, a.handler, a.email, harnessPassword, false); got.code != http.StatusOK { + t.Fatalf("login after the window = %d, want 200", got.code) + } + + // A success clears the email's counter, so two typos then a success leaves + // nothing behind. + for i := 0; i < 2; i++ { + if got := signIn(t, a.handler, a.email, "wrong-password", false); got.code != http.StatusUnauthorized { + t.Fatalf("typo %d = %d, want 401", i+1, got.code) + } + } + if got := signIn(t, a.handler, a.email, harnessPassword, false); got.code != http.StatusOK { + t.Fatalf("login after two typos = %d, want 200", got.code) + } + for i := 0; i < 3; i++ { + if got := signIn(t, a.handler, a.email, "wrong-password", false); got.code != http.StatusUnauthorized { + t.Errorf("after the reset, failure %d = %d, want 401 — the counter did not clear", i+1, got.code) + } + } +} + +// The limit is per email as well as per address, so one account cannot be +// ground down by an attacker who has plenty of addresses — and one address +// cannot work through plenty of accounts. +func TestLoginRateLimitIsPerEmailAndPerAddress(t *testing.T) { + // Three per email, five per address: tight enough to reach both bounds in a + // handful of attempts, and shaped like the production pair, where the + // address budget is the wider one. + a, _ := clockedAPI(t, httpserver.WithLoginRateLimit(3, 5, 10*time.Minute)) + newUser(t, a.h.Pool, a.orgID, "unrelated@example.test", "employer") + + // Exhaust the seeded account's own budget. + for i := 0; i < 3; i++ { + if got := signIn(t, a.handler, a.email, "wrong-password", false); got.code != http.StatusUnauthorized { + t.Fatalf("failure %d = %d, want 401", i+1, got.code) + } + } + if got := signIn(t, a.handler, a.email, harnessPassword, false); got.code != http.StatusTooManyRequests { + t.Fatalf("the blocked email = %d, want 429", got.code) + } + + // A different account from the same address still works: the per-email + // budget is per email, so one account being attacked does not lock out + // everyone else behind the same NAT. + if got := signIn(t, a.handler, "unrelated@example.test", harnessPassword, false); got.code != http.StatusOK { + t.Fatalf("a second email from the same address = %d, want 200", got.code) + } + + // But the address budget is real. Two more failures from here reach five, + // and then nothing from this address gets through, whichever account it + // names. + for i := 0; i < 2; i++ { + if got := signIn(t, a.handler, "unrelated@example.test", "wrong-password", false); got.code != http.StatusUnauthorized { + t.Fatalf("address failure %d = %d, want 401", i+1, got.code) + } + } + if got := signIn(t, a.handler, "unrelated@example.test", harnessPassword, false); got.code != http.StatusTooManyRequests { + t.Errorf("the address budget was not enforced: %d, want 429", got.code) + } +} diff --git a/go-api/internal/httpserver/authfixture_test.go b/go-api/internal/httpserver/authfixture_test.go new file mode 100644 index 0000000..33fc929 --- /dev/null +++ b/go-api/internal/httpserver/authfixture_test.go @@ -0,0 +1,218 @@ +package httpserver_test + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/krow/krow-backend/go-api/internal/auth" + "github.com/krow/krow-backend/go-api/internal/config" + "github.com/krow/krow-backend/go-api/internal/db" + "github.com/krow/krow-backend/go-api/internal/httpserver" + "github.com/krow/krow-backend/go-api/internal/testutil" +) + +// The shared authentication fixture. +// +// Every endpoint in this package except /health and the two auth routes now +// requires a session, so the harness signs in before it hands a test anything. +// That is what keeps the thirty-odd pre-existing tests in api_test.go working +// unchanged: they still call a.do("GET", "/api/v1/…"), and the cookie rides +// along underneath. +// +// The alternative — inserting a session row directly — would test the +// middleware against a session no login ever produced. Signing in through the +// real handler means the fixture itself exercises the flow it depends on. + +// harnessPassword is the password every test account is given. It is a literal +// in a test file for a database that is created and dropped by the same +// process; it is not a credential for anything that outlives the run. +const harnessPassword = "harness-password-not-a-real-secret" + +// harnessHash is argon2id at production cost — about a tenth of a second — so +// it is computed once for the whole package rather than once per test. +var harnessHash = sync.OnceValues(func() (string, error) { + return auth.HashPassword(harnessPassword) +}) + +// setPassword gives a user a known password. +func setPassword(t *testing.T, pool *pgxpool.Pool, userID string) { + t.Helper() + hash, err := harnessHash() + if err != nil { + t.Fatalf("hash the harness password: %v", err) + } + if _, err := pool.Exec(context.Background(), + `UPDATE users SET password_hash = $2::text WHERE id = $1::uuid`, userID, hash); err != nil { + t.Fatalf("set the harness password: %v", err) + } +} + +// setStatus flips a user between 'active' and 'suspended'. +func setStatus(t *testing.T, pool *pgxpool.Pool, userID, status string) { + t.Helper() + if _, err := pool.Exec(context.Background(), + `UPDATE users SET status = $2::text WHERE id = $1::uuid`, userID, status); err != nil { + t.Fatalf("set status %s: %v", status, err) + } +} + +// seededUser is the demo user the fixture loads into the test database. +func seededUser(t *testing.T, pool *pgxpool.Pool) (id, email string) { + t.Helper() + if err := pool.QueryRow(context.Background(), + `SELECT id::text, email::text FROM users ORDER BY created_date, id LIMIT 1`). + Scan(&id, &email); err != nil { + t.Fatalf("read the seeded user: %v", err) + } + return id, email +} + +// newUser adds a user to an organization, with the harness password set. +func newUser(t *testing.T, pool *pgxpool.Pool, orgID, email, role string) string { + t.Helper() + var id string + if err := pool.QueryRow(context.Background(), + `INSERT INTO users (org_id, email, full_name, role) VALUES ($1::uuid, $2::citext, $3, $4) + RETURNING id::text`, orgID, email, "Test User", role).Scan(&id); err != nil { + t.Fatalf("create user %s: %v", email, err) + } + setPassword(t, pool, id) + return id +} + +// loginResult is what signIn observed: the response, and the cookie if one was +// set. Tests assert on both. +type loginResult struct { + code int + body map[string]any + cookie *http.Cookie + raw *httptest.ResponseRecorder +} + +// signIn posts credentials to the real login handler. +func signIn(t *testing.T, handler http.Handler, email, password string, remember bool) loginResult { + t.Helper() + payload, err := json.Marshal(map[string]any{ + "email": email, "password": password, "remember_me": remember, + }) + if err != nil { + t.Fatalf("encode the login payload: %v", err) + } + req := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader(string(payload))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + out := loginResult{code: rec.Code, raw: rec} + if rec.Body.Len() > 0 { + _ = json.Unmarshal(rec.Body.Bytes(), &out.body) + } + for _, c := range rec.Result().Cookies() { + if c.Name == sessionCookie { + out.cookie = c + } + } + return out +} + +// sessionCookie is the name the server uses. Duplicated here rather than +// exported from the package: a test that asserts the cookie name should fail +// when the name changes, not silently follow it. +const sessionCookie = "krow_session" + +// newServer builds a server over a fresh migrated, seeded database. +func newServer(t *testing.T, h *testutil.Harness, origins []string, opts ...httpserver.Option) *httpserver.Server { + t.Helper() + cfg := &config.Config{ + AppEnv: "development", + HTTP: config.HTTPConfig{ + Host: "127.0.0.1", Port: 0, ShutdownTimeout: time.Second, + CORSOrigins: origins, + }, + DB: config.DBConfig{Schema: "public"}, + } + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + srv, err := httpserver.New(cfg, &db.DB{Pool: h.Pool, Schema: "public"}, log, opts...) + if err != nil { + t.Fatalf("build the server: %v", err) + } + return srv +} + +// withSession attaches a cookie to every request passing through, so a test +// about something else — CORS, say — is not also a test about signing in. +func withSession(handler http.Handler, cookie *http.Cookie) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if cookie != nil { + r.AddCookie(cookie) + } + handler.ServeHTTP(w, r) + }) +} + +// newServerWithEnv builds a server for a given APP_ENV, so the cookie's Secure +// flag can be observed on both sides of the development boundary. +func newServerWithEnv(t *testing.T, h *testutil.Harness, appEnv string) http.Handler { + t.Helper() + cfg := &config.Config{ + AppEnv: appEnv, + HTTP: config.HTTPConfig{Host: "127.0.0.1", Port: 0, ShutdownTimeout: time.Second}, + DB: config.DBConfig{Schema: "public"}, + } + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + srv, err := httpserver.New(cfg, &db.DB{Pool: h.Pool, Schema: "public"}, log) + if err != nil { + t.Fatalf("build the %s server: %v", appEnv, err) + } + return srv.Handler() +} + +/* ── Role fixtures (Phase 3D) ───────────────────────────────────────────── */ + +// actor is one signed-in user of a known role. +type actor struct { + name string // for test output only + id string + email string + role string + cookie *http.Cookie +} + +// signInAs creates a user with the given role and signs them in. +func signInAs(t *testing.T, handler http.Handler, pool *pgxpool.Pool, orgID, name, email, role string) actor { + t.Helper() + id := newUserWithRole(t, pool, orgID, email, role) + result := signIn(t, handler, email, harnessPassword, false) + if result.code != http.StatusOK || result.cookie == nil { + t.Fatalf("could not sign in %s (%s): status %d", name, role, result.code) + } + return actor{name: name, id: id, email: email, role: role, cookie: result.cookie} +} + +// newUserWithRole inserts a user with an explicit role and the harness password. +// +// Written straight to the database rather than through the API on purpose: +// users.role is server-owned and there is deliberately no endpoint that sets +// it, which is the property Phase 3D depends on. +func newUserWithRole(t *testing.T, pool *pgxpool.Pool, orgID, email, role string) string { + t.Helper() + var id string + if err := pool.QueryRow(context.Background(), + `INSERT INTO users (org_id, email, full_name, role, account_type) + VALUES ($1::uuid, $2::citext, $3, $4::text, 'employer') RETURNING id::text`, + orgID, email, "Test "+role, role).Scan(&id); err != nil { + t.Fatalf("create %s user %s: %v", role, email, err) + } + setPassword(t, pool, id) + return id +} diff --git a/go-api/internal/httpserver/cors.go b/go-api/internal/httpserver/cors.go new file mode 100644 index 0000000..df5e6ce --- /dev/null +++ b/go-api/internal/httpserver/cors.go @@ -0,0 +1,104 @@ +package httpserver + +import ( + "net/http" + "strconv" + "strings" +) + +// Cross-origin access, for local development. +// +// In Phase 2D the frontend fetches this API directly from the Vite dev server, +// which is a different origin (http://localhost:5173 → http://127.0.0.1:8080). +// Without these headers the browser makes the request and then refuses to let +// the page read the response, which surfaces in the app as an opaque "Failed to +// fetch" with a perfectly healthy 200 in the server log. +// +// This is a transport concern only. No endpoint, request shape, response shape +// or status code in docs/api-contract.md changes because of it. + +// corsMaxAge is how long a browser may cache a preflight result. Ten minutes +// keeps preflight off the hot path without making an allowlist change take an +// awkwardly long time to be noticed in development. +const corsMaxAge = 600 + +// allowedCORSMethods is every method the router actually registers, plus +// OPTIONS for the preflight itself. It is a fixed list rather than something +// derived per path: the browser asks about one method at a time and only needs +// to know it is permitted in general. +var allowedCORSMethods = []string{ + http.MethodGet, http.MethodPost, http.MethodPatch, + http.MethodDelete, http.MethodOptions, +} + +// cors answers preflights and marks cross-origin responses as readable. +// +// Origins are matched exactly against the allowlist and echoed back one at a +// time — never "*" — so adding credentials later does not require rewriting +// this. A request whose Origin is not on the list is served normally, with no +// CORS headers: the API does not refuse it, the browser simply will not hand +// the response to the page. That distinction matters, because curl, the health +// checker and any server-to-server caller send no Origin at all and must not be +// affected by this middleware. +// +// With an empty allowlist the middleware is not installed at all (see New), so +// the same-origin deployment pays nothing for it. +func cors(origins []string) func(http.Handler) http.Handler { + allowed := make(map[string]bool, len(origins)) + for _, o := range origins { + allowed[o] = true + } + methods := strings.Join(allowedCORSMethods, ", ") + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + + // Vary on Origin whether or not this particular origin matched: the + // response differs by Origin, so a cache that ignored it could hand + // one origin's headers to another. + w.Header().Add("Vary", "Origin") + + if origin == "" || !allowed[origin] { + if isPreflight(r) { + // A preflight is never a real request. Answering it with + // the router's 404 for "OPTIONS /api/v1/…" would be + // misleading; 403 says plainly that the origin was refused. + w.WriteHeader(http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + return + } + + w.Header().Set("Access-Control-Allow-Origin", origin) + + if isPreflight(r) { + w.Header().Add("Vary", "Access-Control-Request-Method") + w.Header().Add("Vary", "Access-Control-Request-Headers") + w.Header().Set("Access-Control-Allow-Methods", methods) + // Echo the requested headers rather than listing them. The + // frontend sends only Content-Type today; echoing means a + // future header does not need a change here to be allowed from + // an origin that is already trusted. + if h := r.Header.Get("Access-Control-Request-Headers"); h != "" { + w.Header().Set("Access-Control-Allow-Headers", h) + } else { + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + } + w.Header().Set("Access-Control-Max-Age", strconv.Itoa(corsMaxAge)) + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +// isPreflight identifies the browser's OPTIONS probe. A bare OPTIONS with no +// Access-Control-Request-Method is not a preflight and is left to the router. +func isPreflight(r *http.Request) bool { + return r.Method == http.MethodOptions && + r.Header.Get("Access-Control-Request-Method") != "" +} diff --git a/go-api/internal/httpserver/cors_test.go b/go-api/internal/httpserver/cors_test.go new file mode 100644 index 0000000..64913bc --- /dev/null +++ b/go-api/internal/httpserver/cors_test.go @@ -0,0 +1,143 @@ +package httpserver_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/krow/krow-backend/go-api/internal/testutil" +) + +const devOrigin = "http://localhost:5173" + +// corsAPI is newAPI with an explicit CORS allowlist. It is separate because +// every other test in this package asserts the same-origin behaviour, where the +// middleware is not installed at all. +func corsAPI(t *testing.T, origins ...string) http.Handler { + t.Helper() + h := testutil.New(t) + srv := newServer(t, h, origins) + handler := srv.Handler() + + // The API routes below now require a session. Signing in once and attaching + // the cookie to every request keeps these tests about CORS: without it they + // would assert 401 and prove nothing about the headers. + userID, email := seededUser(t, h.Pool) + setPassword(t, h.Pool, userID) + result := signIn(t, handler, email, harnessPassword, false) + if result.code != http.StatusOK || result.cookie == nil { + t.Fatalf("the CORS harness could not sign in: status %d", result.code) + } + return withSession(handler, result.cookie) +} + +func send(handler http.Handler, method, path string, headers map[string]string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, nil) + for k, v := range headers { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec +} + +// An allowed origin gets its own origin echoed back, never "*". +func TestCORSAllowsConfiguredOrigin(t *testing.T) { + handler := corsAPI(t, devOrigin) + + rec := send(handler, "GET", "/api/v1/job-postings", map[string]string{"Origin": devOrigin}) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != devOrigin { + t.Fatalf("Access-Control-Allow-Origin = %q, want %q", got, devOrigin) + } + if rec.Header().Get("Vary") == "" { + t.Fatal("a response that varies by Origin must say so") + } +} + +// The preflight the browser sends before a PATCH must succeed without reaching +// the router, and must name the methods the frontend uses. +func TestCORSPreflight(t *testing.T) { + handler := corsAPI(t, devOrigin) + + rec := send(handler, "OPTIONS", "/api/v1/job-applications/some-id", map[string]string{ + "Origin": devOrigin, + "Access-Control-Request-Method": "PATCH", + "Access-Control-Request-Headers": "content-type", + }) + if rec.Code != http.StatusNoContent { + t.Fatalf("preflight: expected 204, got %d (%s)", rec.Code, rec.Body.String()) + } + allow := rec.Header().Get("Access-Control-Allow-Methods") + for _, m := range []string{"GET", "POST", "PATCH", "DELETE"} { + if !contains(allow, m) { + t.Fatalf("Access-Control-Allow-Methods = %q, missing %s", allow, m) + } + } + if got := rec.Header().Get("Access-Control-Allow-Headers"); got != "content-type" { + t.Fatalf("Access-Control-Allow-Headers = %q, want the requested header echoed", got) + } + if rec.Header().Get("Access-Control-Max-Age") == "" { + t.Fatal("preflight result should be cacheable") + } +} + +// An origin that is not on the list gets no CORS headers, so the browser will +// not hand the response to the page. +func TestCORSRefusesUnknownOrigin(t *testing.T) { + handler := corsAPI(t, devOrigin) + + rec := send(handler, "GET", "/api/v1/job-postings", map[string]string{ + "Origin": "http://evil.example", + }) + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("an unlisted origin was allowed: %q", got) + } + + pre := send(handler, "OPTIONS", "/api/v1/job-postings", map[string]string{ + "Origin": "http://evil.example", + "Access-Control-Request-Method": "GET", + }) + if pre.Code != http.StatusForbidden { + t.Fatalf("preflight from an unlisted origin: expected 403, got %d", pre.Code) + } +} + +// A caller with no Origin — curl, a health checker, anything server-to-server — +// is untouched by the middleware. +func TestCORSIgnoresRequestsWithoutOrigin(t *testing.T) { + handler := corsAPI(t, devOrigin) + + rec := send(handler, "GET", "/health", nil) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("a request with no Origin got CORS headers: %q", got) + } +} + +// With no allowlist the middleware is not installed, which is the posture for +// any deployment serving the frontend from the API's own origin. +func TestCORSOffByDefault(t *testing.T) { + handler := corsAPI(t) // no origins + + rec := send(handler, "GET", "/api/v1/job-postings", map[string]string{"Origin": devOrigin}) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("CORS answered with no allowlist configured: %q", got) + } +} + +func contains(haystack, needle string) bool { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false +} diff --git a/go-api/internal/httpserver/definitions.go b/go-api/internal/httpserver/definitions.go new file mode 100644 index 0000000..9931057 --- /dev/null +++ b/go-api/internal/httpserver/definitions.go @@ -0,0 +1,214 @@ +package httpserver + +import ( + "net/http" + + "github.com/krow/krow-backend/go-api/internal/authctx" + "github.com/krow/krow-backend/go-api/internal/domain" +) + +func (s *Server) routeDefinitions(mux *http.ServeMux) int { + mux.HandleFunc("GET /api/v1/agent-definitions", s.handleAgentDefinitionsList) + mux.HandleFunc("POST /api/v1/agent-definitions", s.handleAgentDefinitionsCreate) + mux.HandleFunc("GET /api/v1/agent-definitions/{id}", s.handleAgentDefinitionsGet) + mux.HandleFunc("PATCH /api/v1/agent-definitions/{id}", s.handleAgentDefinitionsUpdate) + mux.HandleFunc("DELETE /api/v1/agent-definitions/{id}", s.handleAgentDefinitionsDelete) + + mux.HandleFunc("GET /api/v1/skill-definitions", s.handleSkillDefinitionsList) + mux.HandleFunc("POST /api/v1/skill-definitions", s.handleSkillDefinitionsCreate) + mux.HandleFunc("GET /api/v1/skill-definitions/{id}", s.handleSkillDefinitionsGet) + mux.HandleFunc("PATCH /api/v1/skill-definitions/{id}", s.handleSkillDefinitionsUpdate) + mux.HandleFunc("DELETE /api/v1/skill-definitions/{id}", s.handleSkillDefinitionsDelete) + + return 10 +} + +/* ── Agents ─────────────────────────────────────────────────────────────── */ + +func (s *Server) handleAgentDefinitionsList(w http.ResponseWriter, r *http.Request) { + ident, err := authctx.MustFrom(r.Context()) + if err != nil { + writeError(w, s.log, domain.Internal(err)) + return + } + + params, err := s.definitions.ParseListParams(r.URL.Query()) + if err != nil { + writeError(w, s.log, err) + return + } + + page, err := s.definitions.ListAgents(r.Context(), ident, params) + if err != nil { + writeError(w, s.log, err) + return + } + writePage(w, page) +} + +func (s *Server) handleAgentDefinitionsGet(w http.ResponseWriter, r *http.Request) { + ident, err := authctx.MustFrom(r.Context()) + if err != nil { + writeError(w, s.log, domain.Internal(err)) + return + } + + rec, err := s.definitions.GetAgent(r.Context(), ident, r.PathValue("id")) + if err != nil { + writeError(w, s.log, err) + return + } + writeRecord(w, http.StatusOK, rec) +} + +func (s *Server) handleAgentDefinitionsCreate(w http.ResponseWriter, r *http.Request) { + ident, err := authctx.MustFrom(r.Context()) + if err != nil { + writeError(w, s.log, domain.Internal(err)) + return + } + + body, err := decodeBody(r) + if err != nil { + writeError(w, s.log, err) + return + } + + rec, err := s.definitions.CreateAgent(r.Context(), ident, body) + if err != nil { + writeError(w, s.log, err) + return + } + writeRecord(w, http.StatusCreated, rec) +} + +func (s *Server) handleAgentDefinitionsUpdate(w http.ResponseWriter, r *http.Request) { + ident, err := authctx.MustFrom(r.Context()) + if err != nil { + writeError(w, s.log, domain.Internal(err)) + return + } + + body, err := decodeBody(r) + if err != nil { + writeError(w, s.log, err) + return + } + + rec, err := s.definitions.UpdateAgent(r.Context(), ident, r.PathValue("id"), body) + if err != nil { + writeError(w, s.log, err) + return + } + writeRecord(w, http.StatusOK, rec) +} + +func (s *Server) handleAgentDefinitionsDelete(w http.ResponseWriter, r *http.Request) { + ident, err := authctx.MustFrom(r.Context()) + if err != nil { + writeError(w, s.log, domain.Internal(err)) + return + } + + rec, err := s.definitions.DeleteAgent(r.Context(), ident, r.PathValue("id")) + if err != nil { + writeError(w, s.log, err) + return + } + writeRecord(w, http.StatusOK, rec) +} + +/* ── Skills ─────────────────────────────────────────────────────────────── */ + +func (s *Server) handleSkillDefinitionsList(w http.ResponseWriter, r *http.Request) { + ident, err := authctx.MustFrom(r.Context()) + if err != nil { + writeError(w, s.log, domain.Internal(err)) + return + } + + params, err := s.definitions.ParseListParams(r.URL.Query()) + if err != nil { + writeError(w, s.log, err) + return + } + + page, err := s.definitions.ListSkills(r.Context(), ident, params) + if err != nil { + writeError(w, s.log, err) + return + } + writePage(w, page) +} + +func (s *Server) handleSkillDefinitionsGet(w http.ResponseWriter, r *http.Request) { + ident, err := authctx.MustFrom(r.Context()) + if err != nil { + writeError(w, s.log, domain.Internal(err)) + return + } + + rec, err := s.definitions.GetSkill(r.Context(), ident, r.PathValue("id")) + if err != nil { + writeError(w, s.log, err) + return + } + writeRecord(w, http.StatusOK, rec) +} + +func (s *Server) handleSkillDefinitionsCreate(w http.ResponseWriter, r *http.Request) { + ident, err := authctx.MustFrom(r.Context()) + if err != nil { + writeError(w, s.log, domain.Internal(err)) + return + } + + body, err := decodeBody(r) + if err != nil { + writeError(w, s.log, err) + return + } + + rec, err := s.definitions.CreateSkill(r.Context(), ident, body) + if err != nil { + writeError(w, s.log, err) + return + } + writeRecord(w, http.StatusCreated, rec) +} + +func (s *Server) handleSkillDefinitionsUpdate(w http.ResponseWriter, r *http.Request) { + ident, err := authctx.MustFrom(r.Context()) + if err != nil { + writeError(w, s.log, domain.Internal(err)) + return + } + + body, err := decodeBody(r) + if err != nil { + writeError(w, s.log, err) + return + } + + rec, err := s.definitions.UpdateSkill(r.Context(), ident, r.PathValue("id"), body) + if err != nil { + writeError(w, s.log, err) + return + } + writeRecord(w, http.StatusOK, rec) +} + +func (s *Server) handleSkillDefinitionsDelete(w http.ResponseWriter, r *http.Request) { + ident, err := authctx.MustFrom(r.Context()) + if err != nil { + writeError(w, s.log, domain.Internal(err)) + return + } + + rec, err := s.definitions.DeleteSkill(r.Context(), ident, r.PathValue("id")) + if err != nil { + writeError(w, s.log, err) + return + } + writeRecord(w, http.StatusOK, rec) +} diff --git a/go-api/internal/httpserver/definitions_api_test.go b/go-api/internal/httpserver/definitions_api_test.go new file mode 100644 index 0000000..6fbc399 --- /dev/null +++ b/go-api/internal/httpserver/definitions_api_test.go @@ -0,0 +1,848 @@ +package httpserver_test + +import ( + "fmt" + "net/http" + "testing" + "time" +) + +// Phase 4E — Backend CRUD APIs for authored Agent and Skill definitions. + +const validAgentMD = `--- +id: test-agent +name: Test Agent +description: An authored agent for testing +status: draft +version: 1 +pages: + - candidates +--- + +## Instructions +Execute testing tasks carefully. +` + +const validSkillMD = `--- +id: test-skill +name: Test Skill +description: An authored skill for testing +status: active +pages: + - candidates +--- + +# Test Skill +Skill body instructions. +` + +/* ── 1. Agent Create Tests ────────────────────────────────────────────────── */ + +func TestAgentCreate(t *testing.T) { + r := newRBAC(t) + + // 1. Valid personal agent -> 201 + res := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{ + "markdown": validAgentMD, + "visibility": "personal", + }) + if res.code != http.StatusCreated { + t.Fatalf("create personal agent: got status %d (%v)", res.code, res.body) + } + rec := res.record(t) + if rec["definition_id"] != "test-agent" { + t.Errorf("definition_id = %v, want test-agent", rec["definition_id"]) + } + if rec["name"] != "Test Agent" { + t.Errorf("name = %v, want Test Agent", rec["name"]) + } + if rec["status"] != "draft" { + t.Errorf("status = %v, want draft", rec["status"]) + } + if fmt.Sprint(rec["version"]) != "1" { + t.Errorf("version = %v, want 1", rec["version"]) + } + if rec["visibility"] != "personal" { + t.Errorf("visibility = %v, want personal", rec["visibility"]) + } + + // 3. Personal fields derived from authenticated identity + if rec["owner_user_id"] != r.talA.id { + t.Errorf("owner_user_id = %v, want %s", rec["owner_user_id"], r.talA.id) + } + if rec["created_by"] != r.talA.id { + t.Errorf("created_by = %v, want %s", rec["created_by"], r.talA.id) + } + if rec["org_id"] != r.orgID { + t.Errorf("org_id = %v, want %s", rec["org_id"], r.orgID) + } + + // 2. Valid organization agent -> 201 (by admin) + orgAgentMD := `--- +id: shared-agent +name: Shared Agent +pages: + - candidates +--- +## Instructions +Shared instructions. +` + resOrg := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{ + "markdown": orgAgentMD, + "visibility": "organization", + }) + if resOrg.code != http.StatusCreated { + t.Fatalf("create org agent: got status %d (%v)", resOrg.code, resOrg.body) + } + orgRec := resOrg.record(t) + // 4. Organization fields derived from authenticated identity + if orgRec["visibility"] != "organization" { + t.Errorf("visibility = %v, want organization", orgRec["visibility"]) + } + if orgRec["owner_user_id"] != nil { + t.Errorf("owner_user_id = %v, want nil for organization tier", orgRec["owner_user_id"]) + } + if orgRec["created_by"] != r.admin.id { + t.Errorf("created_by = %v, want %s", orgRec["created_by"], r.admin.id) + } + + // 5, 6, 7. Client-supplied org_id, owner_user_id, created_by cannot override session + manipulatedMD := `--- +id: spoof-agent +name: Spoof Agent +pages: + - candidates +--- +` + resSpoof := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{ + "markdown": manipulatedMD, + "visibility": "personal", + "org_id": r.otherOrgID, + "owner_user_id": r.talB.id, + "created_by": r.admin.id, + }) + if resSpoof.code != http.StatusCreated { + t.Fatalf("create spoofed agent: status %d", resSpoof.code) + } + spoofRec := resSpoof.record(t) + if spoofRec["org_id"] != r.orgID { + t.Errorf("org_id spoofed: got %v, want %s", spoofRec["org_id"], r.orgID) + } + if spoofRec["owner_user_id"] != r.talA.id { + t.Errorf("owner_user_id spoofed: got %v, want %s", spoofRec["owner_user_id"], r.talA.id) + } + if spoofRec["created_by"] != r.talA.id { + t.Errorf("created_by spoofed: got %v, want %s", spoofRec["created_by"], r.talA.id) + } + + // 8. Invalid Markdown -> 422 + resEmpty := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{ + "markdown": "", + }) + if resEmpty.code != http.StatusUnprocessableEntity { + t.Errorf("empty markdown: got %d, want 422", resEmpty.code) + } + + // 9. Invalid definition_id -> 422 + badIDMD := `--- +id: Bad_ID! +name: Bad ID Agent +pages: + - candidates +--- +` + resBadID := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{ + "markdown": badIDMD, + }) + if resBadID.code != http.StatusUnprocessableEntity { + t.Errorf("bad definition_id: got %d, want 422 (%v)", resBadID.code, resBadID.body) + } + + // 10. Missing name -> 422 + noNameMD := `--- +id: no-name-agent +pages: + - candidates +--- +` + resNoName := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{ + "markdown": noNameMD, + }) + if resNoName.code != http.StatusUnprocessableEntity { + t.Errorf("missing name: got %d, want 422 (%v)", resNoName.code, resNoName.body) + } + + // 11. Version > MaxVersion -> 422 + hugeVersionMD := `--- +id: huge-v +name: Huge Version +version: 999999999999999 +pages: + - candidates +--- +` + resHugeV := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{ + "markdown": hugeVersionMD, + }) + if resHugeV.code != http.StatusUnprocessableEntity { + t.Errorf("huge version: got %d, want 422 (%v)", resHugeV.code, resHugeV.body) + } + + // 12. Duplicate personal definition -> 409 + resDupPersonal := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{ + "markdown": validAgentMD, + "visibility": "personal", + }) + if resDupPersonal.code != http.StatusConflict { + t.Errorf("duplicate personal agent: got %d, want 409 (%v)", resDupPersonal.code, resDupPersonal.body) + } + + // 13. Duplicate organization definition -> 409 + resDupOrg := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{ + "markdown": orgAgentMD, + "visibility": "organization", + }) + if resDupOrg.code != http.StatusConflict { + t.Errorf("duplicate org agent: got %d, want 409 (%v)", resDupOrg.code, resDupOrg.body) + } + + // Shadow-by-id: personal agent with SAME id as organization agent succeeds! + resShadow := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{ + "markdown": orgAgentMD, + "visibility": "personal", + }) + if resShadow.code != http.StatusCreated { + t.Errorf("shadow personal agent: got %d, want 201 (%v)", resShadow.code, resShadow.body) + } + + // Talent cannot create organization definition -> 403 + talOrgMD := `--- +id: tal-org +name: Tal Org +pages: + - candidates +--- +` + resTalOrg := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{ + "markdown": talOrgMD, + "visibility": "organization", + }) + if resTalOrg.code != http.StatusForbidden { + t.Errorf("talent create org agent: got %d, want 403 (%v)", resTalOrg.code, resTalOrg.body) + } +} + +/* ── 2. Skill Create Tests ────────────────────────────────────────────────── */ + +func TestSkillCreate(t *testing.T) { + r := newRBAC(t) + + // 14. Valid personal skill -> 201 + res := r.as(r.talA, "POST", "/api/v1/skill-definitions", map[string]any{ + "markdown": validSkillMD, + "visibility": "personal", + }) + if res.code != http.StatusCreated { + t.Fatalf("create personal skill: got %d (%v)", res.code, res.body) + } + rec := res.record(t) + if rec["definition_id"] != "test-skill" { + t.Errorf("definition_id = %v, want test-skill", rec["definition_id"]) + } + if rec["name"] != "Test Skill" { + t.Errorf("name = %v, want Test Skill", rec["name"]) + } + if rec["status"] != "active" { + t.Errorf("status = %v, want active", rec["status"]) + } + if rec["visibility"] != "personal" { + t.Errorf("visibility = %v, want personal", rec["visibility"]) + } + if rec["owner_user_id"] != r.talA.id { + t.Errorf("owner_user_id = %v, want %s", rec["owner_user_id"], r.talA.id) + } + // 21. Skills do NOT have a version column + if _, hasVersion := rec["version"]; hasVersion { + t.Errorf("skill record has version field; skills must not have a version") + } + + // 15. Valid organization skill -> 201 + orgSkillMD := `--- +id: org-skill +name: Org Skill +status: active +pages: + - candidates +--- +# Org Skill +` + resOrg := r.as(r.admin, "POST", "/api/v1/skill-definitions", map[string]any{ + "markdown": orgSkillMD, + "visibility": "organization", + }) + if resOrg.code != http.StatusCreated { + t.Fatalf("create org skill: got %d (%v)", resOrg.code, resOrg.body) + } + + // 16. Invalid Markdown -> 422 + resEmpty := r.as(r.admin, "POST", "/api/v1/skill-definitions", map[string]any{ + "markdown": "", + }) + if resEmpty.code != http.StatusUnprocessableEntity { + t.Errorf("empty skill markdown: got %d, want 422", resEmpty.code) + } + + // 17. Invalid definition_id -> 422 + badIDMD := `--- +id: BAD_SKILL +name: Bad Skill +pages: + - candidates +--- +` + resBadID := r.as(r.admin, "POST", "/api/v1/skill-definitions", map[string]any{ + "markdown": badIDMD, + }) + if resBadID.code != http.StatusUnprocessableEntity { + t.Errorf("bad skill id: got %d, want 422", resBadID.code) + } + + // 18. Invalid page -> 422 + badPageMD := `--- +id: bad-page-skill +name: Bad Page Skill +pages: + - totally_unknown_page_xyz +--- +` + resBadPage := r.as(r.admin, "POST", "/api/v1/skill-definitions", map[string]any{ + "markdown": badPageMD, + }) + if resBadPage.code != http.StatusUnprocessableEntity { + t.Errorf("bad skill page: got %d, want 422 (%v)", resBadPage.code, resBadPage.body) + } + + // 19. Duplicate personal skill -> 409 + resDupPers := r.as(r.talA, "POST", "/api/v1/skill-definitions", map[string]any{ + "markdown": validSkillMD, + "visibility": "personal", + }) + if resDupPers.code != http.StatusConflict { + t.Errorf("duplicate personal skill: got %d, want 409 (%v)", resDupPers.code, resDupPers.body) + } + + // 20. Duplicate organization skill -> 409 + resDupOrg := r.as(r.admin, "POST", "/api/v1/skill-definitions", map[string]any{ + "markdown": orgSkillMD, + "visibility": "organization", + }) + if resDupOrg.code != http.StatusConflict { + t.Errorf("duplicate org skill: got %d, want 409 (%v)", resDupOrg.code, resDupOrg.body) + } +} + +/* ── 3. List Tests ────────────────────────────────────────────────────────── */ + +func TestDefinitionsList(t *testing.T) { + r := newRBAC(t) + + // Create: + // - 1 org agent (admin) + // - 1 personal agent for talA + // - 1 personal agent for talB + // - 1 org agent for outsider (in other org) + r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{ + "markdown": `--- +id: org-agent-1 +name: Org Agent 1 +pages: + - candidates +--- +`, + "visibility": "organization", + }) + r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{ + "markdown": `--- +id: tala-agent +name: TalA Agent +pages: + - candidates +--- +`, + "visibility": "personal", + }) + r.as(r.talB, "POST", "/api/v1/agent-definitions", map[string]any{ + "markdown": `--- +id: talb-agent +name: TalB Agent +pages: + - candidates +--- +`, + "visibility": "personal", + }) + r.as(r.outsider, "POST", "/api/v1/agent-definitions", map[string]any{ + "markdown": `--- +id: outsider-agent +name: Outsider Agent +pages: + - candidates +--- +`, + "visibility": "organization", + }) + + // 22, 23, 24, 25. Scoping assertions + talAList := r.as(r.talA, "GET", "/api/v1/agent-definitions", nil) + if talAList.code != http.StatusOK { + t.Fatalf("talA list: %d", talAList.code) + } + talARecs := talAList.records(t) + talAIDMap := map[string]bool{} + for _, rec := range talARecs { + talAIDMap[rec["definition_id"].(string)] = true + } + + if !talAIDMap["org-agent-1"] { + t.Errorf("talA should see org-agent-1") + } + if !talAIDMap["tala-agent"] { + t.Errorf("talA should see tala-agent") + } + if talAIDMap["talb-agent"] { + t.Errorf("talA must NOT see talB's personal agent") + } + if talAIDMap["outsider-agent"] { + t.Errorf("talA must NOT see outsider organization's agent") + } + + // 26. Visibility filter + onlyPersonal := r.as(r.talA, "GET", "/api/v1/agent-definitions?visibility=personal", nil).records(t) + for _, rec := range onlyPersonal { + if rec["visibility"] != "personal" { + t.Errorf("expected only personal visibility, got %v", rec["visibility"]) + } + } + onlyOrg := r.as(r.talA, "GET", "/api/v1/agent-definitions?visibility=organization", nil).records(t) + for _, rec := range onlyOrg { + if rec["visibility"] != "organization" { + t.Errorf("expected only organization visibility, got %v", rec["visibility"]) + } + } + badVis := r.as(r.talA, "GET", "/api/v1/agent-definitions?visibility=invalid_vis", nil) + if badVis.code != http.StatusBadRequest { + t.Errorf("bad visibility filter: got %d, want 400", badVis.code) + } + + // 27. Status filter + filteredStatus := r.as(r.talA, "GET", "/api/v1/agent-definitions?status=draft", nil).records(t) + for _, rec := range filteredStatus { + if rec["status"] != "draft" { + t.Errorf("expected draft status, got %v", rec["status"]) + } + } + + // 28. Definition ID filter + defIDList := r.as(r.talA, "GET", "/api/v1/agent-definitions?definition_id=tala-agent", nil).records(t) + if len(defIDList) != 1 || defIDList[0]["definition_id"] != "tala-agent" { + t.Errorf("definition_id filter failed: got %v", defIDList) + } + + // 29. Pagination + page1 := r.as(r.talA, "GET", "/api/v1/agent-definitions?limit=1&offset=0", nil) + meta1 := page1.meta(t) + if fmt.Sprint(meta1["limit"]) != "1" || fmt.Sprint(meta1["offset"]) != "0" { + t.Errorf("pagination meta: %v", meta1) + } + + // 30. Stable sorting + sortedAsc := r.as(r.talA, "GET", "/api/v1/agent-definitions?sort=definition_id", nil).records(t) + if len(sortedAsc) >= 2 { + id0 := sortedAsc[0]["definition_id"].(string) + id1 := sortedAsc[1]["definition_id"].(string) + if id0 > id1 { + t.Errorf("ascending sort failed: %s > %s", id0, id1) + } + } +} + +/* ── 4. Get By ID Tests ───────────────────────────────────────────────────── */ + +func TestDefinitionsGet(t *testing.T) { + r := newRBAC(t) + + // Create personal agent for talA + createA := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{ + "markdown": `--- +id: get-pers-a +name: Get Pers A +pages: + - candidates +--- +`, + "visibility": "personal", + }) + idPersA := createA.record(t)["id"].(string) + + // Create org agent + createOrg := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{ + "markdown": `--- +id: get-org +name: Get Org +pages: + - candidates +--- +`, + "visibility": "organization", + }) + idOrg := createOrg.record(t)["id"].(string) + + // Create personal agent for outsider + createOutsider := r.as(r.outsider, "POST", "/api/v1/agent-definitions", map[string]any{ + "markdown": `--- +id: get-pers-outsider +name: Get Pers Outsider +pages: + - candidates +--- +`, + "visibility": "personal", + }) + idOutsider := createOutsider.record(t)["id"].(string) + + // 31. Own personal definition -> 200 + getPersA := r.as(r.talA, "GET", "/api/v1/agent-definitions/"+idPersA, nil) + if getPersA.code != http.StatusOK { + t.Errorf("get own personal agent: %d", getPersA.code) + } + + // 32. Same-org organization definition -> 200 + getOrgByTal := r.as(r.talA, "GET", "/api/v1/agent-definitions/"+idOrg, nil) + if getOrgByTal.code != http.StatusOK { + t.Errorf("get same-org agent: %d", getOrgByTal.code) + } + + // 33. Other user's personal definition -> 404 (inaccessible) + getPersByTalB := r.as(r.talB, "GET", "/api/v1/agent-definitions/"+idPersA, nil) + if getPersByTalB.code != http.StatusNotFound { + t.Errorf("get other user personal agent: got %d, want 404", getPersByTalB.code) + } + + // 34. Other organization's definition -> 404 (inaccessible) + getOutsiderByTalA := r.as(r.talA, "GET", "/api/v1/agent-definitions/"+idOutsider, nil) + if getOutsiderByTalA.code != http.StatusNotFound { + t.Errorf("get outsider definition: got %d, want 404", getOutsiderByTalA.code) + } + + // Malformed UUID -> 404 + getMalformed := r.as(r.talA, "GET", "/api/v1/agent-definitions/not-a-uuid", nil) + if getMalformed.code != http.StatusNotFound { + t.Errorf("get malformed uuid: got %d, want 404", getMalformed.code) + } +} + +/* ── 5. Patch Tests ───────────────────────────────────────────────────────── */ + +func TestDefinitionsPatch(t *testing.T) { + r := newRBAC(t) + + // Create personal agent for talA + createA := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{ + "markdown": `--- +id: patch-agent +name: Initial Name +version: 1 +pages: + - candidates +--- +Initial Body`, + "visibility": "personal", + }) + idA := createA.record(t)["id"].(string) + origCreated := createA.record(t)["created_date"].(string) + origUpdated := createA.record(t)["updated_date"].(string) + + time.Sleep(10 * time.Millisecond) + + // 35, 36, 37, 38. Markdown update -> 200, projections updated, markdown verbatim, updated_date changed + updatedMD := `--- +id: patch-agent +name: Updated Name +version: 2 +status: published +pages: + - candidates + - positions +--- + +# Updated Body +Verbatim content with trailing spaces +` + patchRes := r.as(r.talA, "PATCH", "/api/v1/agent-definitions/"+idA, map[string]any{ + "markdown": updatedMD, + }) + if patchRes.code != http.StatusOK { + t.Fatalf("patch agent: %d (%v)", patchRes.code, patchRes.body) + } + patchedRec := patchRes.record(t) + if patchedRec["name"] != "Updated Name" { + t.Errorf("name = %v, want Updated Name", patchedRec["name"]) + } + if patchedRec["status"] != "published" { + t.Errorf("status = %v, want published", patchedRec["status"]) + } + if fmt.Sprint(patchedRec["version"]) != "2" { + t.Errorf("version = %v, want 2", patchedRec["version"]) + } + if patchedRec["markdown"] != updatedMD { + t.Errorf("markdown not verbatim:\n got: %q\nwant: %q", patchedRec["markdown"], updatedMD) + } + if patchedRec["created_date"] != origCreated { + t.Errorf("created_date changed on patch") + } + if patchedRec["updated_date"] == origUpdated { + t.Errorf("updated_date did not advance") + } + + // 39. Invalid Markdown update -> 422 + badPatch := r.as(r.talA, "PATCH", "/api/v1/agent-definitions/"+idA, map[string]any{ + "markdown": "--- invalid yaml --", + }) + if badPatch.code != http.StatusUnprocessableEntity { + t.Errorf("invalid patch md: got %d, want 422", badPatch.code) + } + + // 40. Server-owned fields cannot be modified + spoofPatch := r.as(r.talA, "PATCH", "/api/v1/agent-definitions/"+idA, map[string]any{ + "owner_user_id": r.talB.id, + "org_id": r.otherOrgID, + "created_by": r.admin.id, + }) + if spoofPatch.code != http.StatusOK { + t.Errorf("spoof patch status: %d", spoofPatch.code) + } + reread := r.as(r.talA, "GET", "/api/v1/agent-definitions/"+idA, nil).record(t) + if reread["owner_user_id"] != r.talA.id { + t.Errorf("owner_user_id altered on patch: %v", reread["owner_user_id"]) + } + if reread["org_id"] != r.orgID { + t.Errorf("org_id altered on patch: %v", reread["org_id"]) + } + + // 41. Unauthorized update: talB cannot patch talA's definition -> 404 + resTalBPatch := r.as(r.talB, "PATCH", "/api/v1/agent-definitions/"+idA, map[string]any{ + "status": "archived", + }) + if resTalBPatch.code != http.StatusNotFound { + t.Errorf("talB patch talA: got %d, want 404", resTalBPatch.code) + } + + // Create org agent + createOrg := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{ + "markdown": `--- +id: org-for-patch +name: Org Patch +pages: + - candidates +--- +`, + "visibility": "organization", + }) + idOrg := createOrg.record(t)["id"].(string) + + // Talent cannot patch org definition -> 403 + talOrgPatch := r.as(r.talA, "PATCH", "/api/v1/agent-definitions/"+idOrg, map[string]any{ + "status": "archived", + }) + if talOrgPatch.code != http.StatusForbidden { + t.Errorf("talent patch org agent: got %d, want 403", talOrgPatch.code) + } + + // Employer can patch org definition -> 200 + empOrgPatch := r.as(r.empA, "PATCH", "/api/v1/agent-definitions/"+idOrg, map[string]any{ + "status": "archived", + }) + if empOrgPatch.code != http.StatusOK { + t.Errorf("employer patch org agent: got %d, want 200", empOrgPatch.code) + } + + // Visibility is immutable after creation -> 422 + visPatch := r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+idOrg, map[string]any{ + "visibility": "personal", + }) + if visPatch.code != http.StatusUnprocessableEntity { + t.Errorf("visibility mutation: got %d, want 422 (%v)", visPatch.code, visPatch.body) + } +} + +/* ── 6. Delete Tests ──────────────────────────────────────────────────────── */ + +func TestDefinitionsDelete(t *testing.T) { + r := newRBAC(t) + + // Create personal agent for talA + createA := r.as(r.talA, "POST", "/api/v1/agent-definitions", map[string]any{ + "markdown": `--- +id: del-agent-a +name: Del Agent A +pages: + - candidates +--- +`, + "visibility": "personal", + }) + idA := createA.record(t)["id"].(string) + + // Create org agent + createOrg := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{ + "markdown": `--- +id: del-agent-org +name: Del Agent Org +pages: + - candidates +--- +`, + "visibility": "organization", + }) + idOrg := createOrg.record(t)["id"].(string) + + // 46. Talent cannot delete org definition -> 403 + talDelOrg := r.as(r.talA, "DELETE", "/api/v1/agent-definitions/"+idOrg, nil) + if talDelOrg.code != http.StatusForbidden { + t.Errorf("talent delete org agent: got %d, want 403", talDelOrg.code) + } + + // 44, 48, 49. Owner can delete personal agent -> 200, returns { "data": { "id": ... } }, subsequent GET -> 404 + delA := r.as(r.talA, "DELETE", "/api/v1/agent-definitions/"+idA, nil) + if delA.code != http.StatusOK { + t.Fatalf("delete personal agent: got %d", delA.code) + } + delRec := delA.record(t) + if delRec["id"] != idA { + t.Errorf("delete response id = %v, want %s", delRec["id"], idA) + } + getAAfter := r.as(r.talA, "GET", "/api/v1/agent-definitions/"+idA, nil) + if getAAfter.code != http.StatusNotFound { + t.Errorf("subsequent GET deleted agent: got %d, want 404", getAAfter.code) + } + + // 45. Operator (employer) can delete org definition -> 200 + delOrg := r.as(r.empA, "DELETE", "/api/v1/agent-definitions/"+idOrg, nil) + if delOrg.code != http.StatusOK { + t.Fatalf("employer delete org agent: got %d", delOrg.code) + } + getOrgAfter := r.as(r.admin, "GET", "/api/v1/agent-definitions/"+idOrg, nil) + if getOrgAfter.code != http.StatusNotFound { + t.Errorf("subsequent GET deleted org agent: got %d, want 404", getOrgAfter.code) + } + + // Idempotent delete on non-existent UUID -> 200 + missingUUID := "00000000-0000-0000-0000-000000000000" + delMissing := r.as(r.talA, "DELETE", "/api/v1/agent-definitions/"+missingUUID, nil) + if delMissing.code != http.StatusOK { + t.Errorf("idempotent delete: got %d, want 200", delMissing.code) + } +} + +/* ── 7. Security and SQL Injection ────────────────────────────────────────── */ + +func TestSecurityAndSQLInjection(t *testing.T) { + r := newRBAC(t) + + // SQL injection in filter + sqliList := r.as(r.talA, "GET", "/api/v1/agent-definitions?definition_id=x'%20OR%20'1'='1", nil) + if sqliList.code != http.StatusOK { + t.Errorf("sqli filter request failed: %d", sqliList.code) + } + if len(sqliList.records(t)) != 0 { + t.Errorf("sqli in definition_id filter leaked records") + } + + // SQL injection in sort + sqliSort := r.as(r.talA, "GET", "/api/v1/agent-definitions?sort=name%20DESC%3BDROP%20TABLE%20users%3B", nil) + if sqliSort.code != http.StatusBadRequest { + t.Errorf("sqli in sort should be rejected as invalid query: got %d (%v)", sqliSort.code, sqliSort.body) + } + + // Unauthenticated requests -> 401 + unauthList := r.doAnon("GET", "/api/v1/agent-definitions", nil) + if unauthList.code != http.StatusUnauthorized { + t.Errorf("unauth list: got %d, want 401", unauthList.code) + } + unauthCreate := r.doAnon("POST", "/api/v1/agent-definitions", map[string]any{ + "markdown": validAgentMD, + }) + if unauthCreate.code != http.StatusUnauthorized { + t.Errorf("unauth create: got %d, want 401", unauthCreate.code) + } +} + +/* ── 8. Full CRUD & Projection Consistency Flow ───────────────────────────── */ + +func TestFullCRUDFlowAndProjections(t *testing.T) { + r := newRBAC(t) + + // 58. Create + createRes := r.as(r.empA, "POST", "/api/v1/skill-definitions", map[string]any{ + "markdown": validSkillMD, + "visibility": "organization", + }) + if createRes.code != http.StatusCreated { + t.Fatalf("create skill failed: %d (%v)", createRes.code, createRes.body) + } + id := createRes.record(t)["id"].(string) + + // 59. List includes created record + listRes := r.as(r.empA, "GET", "/api/v1/skill-definitions?definition_id=test-skill", nil) + if listRes.code != http.StatusOK || len(listRes.records(t)) == 0 { + t.Fatalf("list skill failed: %d (%v)", listRes.code, listRes.body) + } + + // 60. Get created record and verify projections + getRes := r.as(r.talA, "GET", "/api/v1/skill-definitions/"+id, nil) + if getRes.code != http.StatusOK { + t.Fatalf("get skill failed: %d", getRes.code) + } + rec := getRes.record(t) + if rec["definition_id"] != "test-skill" || rec["name"] != "Test Skill" || rec["status"] != "active" { + t.Errorf("projection mismatch on get: %v", rec) + } + if rec["markdown"] != validSkillMD { + t.Errorf("markdown not verbatim on get") + } + + // 61. Patch + newSkillMD := `--- +id: test-skill +name: Updated Skill Name +status: inactive +pages: + - candidates + - profile +--- +# Updated Skill Body +` + patchRes := r.as(r.empA, "PATCH", "/api/v1/skill-definitions/"+id, map[string]any{ + "markdown": newSkillMD, + }) + if patchRes.code != http.StatusOK { + t.Fatalf("patch skill failed: %d (%v)", patchRes.code, patchRes.body) + } + patchedRec := patchRes.record(t) + if patchedRec["name"] != "Updated Skill Name" || patchedRec["status"] != "inactive" { + t.Errorf("projection not updated on patch: %v", patchedRec) + } + if patchedRec["markdown"] != newSkillMD { + t.Errorf("markdown not verbatim on patch") + } + + // 62. Delete + delRes := r.as(r.empA, "DELETE", "/api/v1/skill-definitions/"+id, nil) + if delRes.code != http.StatusOK { + t.Fatalf("delete skill failed: %d", delRes.code) + } + getAfterDel := r.as(r.empA, "GET", "/api/v1/skill-definitions/"+id, nil) + if getAfterDel.code != http.StatusNotFound { + t.Errorf("get after delete: got %d, want 404", getAfterDel.code) + } +} diff --git a/go-api/internal/httpserver/me.go b/go-api/internal/httpserver/me.go new file mode 100644 index 0000000..4f078b1 --- /dev/null +++ b/go-api/internal/httpserver/me.go @@ -0,0 +1,326 @@ +package httpserver + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "strings" + + "github.com/krow/krow-backend/go-api/internal/authctx" + "github.com/krow/krow-backend/go-api/internal/domain" + "github.com/krow/krow-backend/go-api/internal/repo" +) + +// The current-user endpoints. See api-contract.md §9. +// +// "Current" now means the user behind the session cookie, resolved by the +// authentication middleware and read from the request context. Before Phase 3C +// it meant the organization's oldest user, found with ORDER BY created_date +// LIMIT 1 — a placeholder that was correct only because there was exactly one. + +// preferenceColumns maps the frontend's camelCase preference keys onto their +// columns. Anything not listed here lives in user_preferences.extra — which is +// where customSkills and customAgents, every account-authored definition, +// currently are. +var preferenceColumns = map[string]string{ + "owliverDefault": "owliver_default", + "compactDensity": "compact_density", + "emailDigest": "email_digest", +} + +// userColumns is the projection for a user record. +const userColumns = `id::text AS id, legacy_id, full_name, email::text AS email, + role, account_type, status, + to_char(created_date AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS created_date, + to_char(updated_date AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS updated_date` + +// updatableUserFields are the only columns PATCH /me may write, and this map is +// the entire authority on that: a column absent from it cannot be reached by +// this endpoint at all, whatever the request body says. +// +// full_name the Profile page's display-name field. +// account_type which product surface the person is looking at — Employer or +// +// Talent. Layout.jsx writes it when the viewer switches. +// Explicitly NOT an authorization field (Phase 3B decision 1); it +// is a display attribute, and users.role is what authorizes. +// +// Everything else is server-owned. `role` used to be in this map, which meant +// any signed-in user could promote themselves to admin with a one-line PATCH +// the moment sessions existed. It was harmless while there was no +// authentication and a live privilege-escalation path the instant there was. +// See serverOwnedUserFields. +var updatableUserFields = map[string]string{ + "full_name": "text", + "account_type": "text", +} + +// serverOwnedUserFields are the fields a user must never write about +// themselves, listed by name so an attempt can be recognised and logged rather +// than silently dropped in with every other unknown key. +// +// They are ignored, not rejected: a PATCH body is "whichever fields the caller +// sent", the endpoint has always ignored what it does not own, and the response +// returns the user as they actually are — so a caller who asks for a role +// change gets a 200 whose body shows the role unchanged. What is new is that +// the attempt is now visible in the log, because a client asking to change its +// own role is worth knowing about even when the answer is no. +var serverOwnedUserFields = map[string]bool{ + "id": true, + "org_id": true, + "role": true, + "password_hash": true, + "status": true, + "email": true, + "legacy_id": true, + "last_login_at": true, + "created_date": true, + "updated_date": true, +} + +func (s *Server) routeMe(mux *http.ServeMux) int { + mux.HandleFunc("GET /api/v1/me", s.handleMeGet) + mux.HandleFunc("PATCH /api/v1/me", s.handleMePatch) + mux.HandleFunc("GET /api/v1/me/preferences", s.handlePreferencesGet) + mux.HandleFunc("PATCH /api/v1/me/preferences", s.handlePreferencesPatch) + return 4 +} + +// userRecord reads one user by id, with preferences embedded. +// +// Embedded rather than a sibling resource because krowHooks.js:42 reads +// `user?.preferences` straight off the object returned by auth.me(). +// +// The id is always one this server resolved from a session — never a value off +// the request. There is deliberately no variant of this function that takes an +// identifier from a caller. +func (s *Server) userRecord(ctx context.Context, q repo.Querier, userID string) (domain.Record, error) { + rows, err := q.Query(ctx, + `SELECT `+userColumns+` FROM users WHERE id = $1::uuid`, + userID) + if err != nil { + return nil, domain.Internal(err) + } + defer rows.Close() + + fields := rows.FieldDescriptions() + if !rows.Next() { + // Unreachable in practice: the middleware just read this row to build + // the identity. Reported rather than papered over. + return nil, domain.NotFound("User", "current") + } + vals, err := rows.Values() + if err != nil { + return nil, domain.Internal(err) + } + user := make(domain.Record, len(fields)+1) + for i, f := range fields { + user[string(f.Name)] = vals[i] + } + rows.Close() + + prefs, err := s.preferences(ctx, q, user["id"].(string)) + if err != nil { + return nil, err + } + user["preferences"] = prefs + return user, nil +} + +// preferences reads the three columns plus the extra blob, flattened into the +// single camelCase object the frontend expects. +func (s *Server) preferences(ctx context.Context, q repo.Querier, userID string) (map[string]any, error) { + var owliver, compact, digest bool + var extra []byte + err := q.QueryRow(ctx, + `SELECT owliver_default, compact_density, email_digest, extra + FROM user_preferences WHERE user_id = $1::uuid`, userID). + Scan(&owliver, &compact, &digest, &extra) + + out := map[string]any{} + if err != nil { + // No row yet is a legitimate state: the defaults below are the schema's. + return map[string]any{"owliverDefault": true, "compactDensity": false, "emailDigest": true}, nil + } + if len(extra) > 0 { + _ = json.Unmarshal(extra, &out) + } + out["owliverDefault"] = owliver + out["compactDensity"] = compact + out["emailDigest"] = digest + return out, nil +} + +func (s *Server) handleMeGet(w http.ResponseWriter, r *http.Request) { + id, err := authctx.MustFrom(r.Context()) + if err != nil { + writeError(w, s.log, domain.Internal(err)) + return + } + user, err := s.userRecord(r.Context(), s.db.Pool, id.UserID) + if err != nil { + writeError(w, s.log, err) + return + } + writeRecord(w, http.StatusOK, user) +} + +func (s *Server) handleMePatch(w http.ResponseWriter, r *http.Request) { + id, err := authctx.MustFrom(r.Context()) + if err != nil { + writeError(w, s.log, domain.Internal(err)) + return + } + body, err := decodeBody(r) + if err != nil { + writeError(w, s.log, err) + return + } + + // The row to update is the session's user. Note what is NOT consulted: the + // body may contain an "id", and it is ignored — writing to whichever user + // the caller names is the whole of the vulnerability this avoids. + sets, args := []string{}, []any{id.UserID} + details := map[string]string{} + for k, v := range body { + if k == "preferences" { + details[k] = "update preferences through /api/v1/me/preferences" + continue + } + pgType, ok := updatableUserFields[k] + if !ok { + if serverOwnedUserFields[k] { + s.log.Warn("ignored an attempt to write a server-owned user field", + "field", k, "user_id", id.UserID, "session_id", id.SessionID) + } + continue // server-owned, or simply not a column: ignored either way + } + str, isStr := v.(string) + if !isStr { + details[k] = "expected a string" + continue + } + args = append(args, str) + sets = append(sets, k+" = $"+strconv.Itoa(len(args))+"::"+pgType) + } + if len(details) > 0 { + writeError(w, s.log, domain.Validation("User payload is not valid", details)) + return + } + + if len(sets) > 0 { + // Every column name in `sets` came from updatableUserFields, which is a + // literal map in this file. No identifier here is caller-supplied; the + // values are all bind parameters. + q := "UPDATE users SET " + strings.Join(sets, ", ") + ", updated_date = now() WHERE id = $1::uuid" + if _, err := s.db.Pool.Exec(r.Context(), q, args...); err != nil { + writeError(w, s.log, domain.Internal(err)) + return + } + } + + user, err := s.userRecord(r.Context(), s.db.Pool, id.UserID) + if err != nil { + writeError(w, s.log, err) + return + } + writeRecord(w, http.StatusOK, user) +} + +func (s *Server) handlePreferencesGet(w http.ResponseWriter, r *http.Request) { + id, err := authctx.MustFrom(r.Context()) + if err != nil { + writeError(w, s.log, domain.Internal(err)) + return + } + prefs, err := s.preferences(r.Context(), s.db.Pool, id.UserID) + if err != nil { + writeError(w, s.log, err) + return + } + writeJSON(w, http.StatusOK, envelope{Data: prefs}) +} + +// handlePreferencesPatch shallow-merges the supplied keys and returns the whole +// merged object, matching auth.updatePreferences(). +func (s *Server) handlePreferencesPatch(w http.ResponseWriter, r *http.Request) { + id, err := authctx.MustFrom(r.Context()) + if err != nil { + writeError(w, s.log, domain.Internal(err)) + return + } + body, err := decodeBody(r) + if err != nil { + writeError(w, s.log, err) + return + } + userID := id.UserID + current, err := s.preferences(r.Context(), s.db.Pool, userID) + if err != nil { + writeError(w, s.log, err) + return + } + + merged := current + if merged == nil { + merged = map[string]any{} + } + details := map[string]string{} + for k, v := range body { + if _, isColumn := preferenceColumns[k]; isColumn { + if _, ok := v.(bool); !ok { + details[k] = "expected a boolean" + continue + } + } + merged[k] = v + } + if len(details) > 0 { + writeError(w, s.log, domain.Validation("preferences payload is not valid", details)) + return + } + + // Split the merged object back into its columns and the extra blob. + extra := map[string]any{} + for k, v := range merged { + if _, isColumn := preferenceColumns[k]; !isColumn { + extra[k] = v + } + } + extraJSON, err := json.Marshal(extra) + if err != nil { + writeError(w, s.log, domain.Validation("preferences are not encodable as JSON", nil)) + return + } + + _, err = s.db.Pool.Exec(r.Context(), + `INSERT INTO user_preferences (user_id, owliver_default, compact_density, email_digest, extra, updated_date) + VALUES ($1::uuid, $2::boolean, $3::boolean, $4::boolean, $5::jsonb, now()) + ON CONFLICT (user_id) DO UPDATE SET + owliver_default = EXCLUDED.owliver_default, + compact_density = EXCLUDED.compact_density, + email_digest = EXCLUDED.email_digest, + extra = EXCLUDED.extra, + updated_date = now()`, + userID, truthy(merged["owliverDefault"], true), truthy(merged["compactDensity"], false), + truthy(merged["emailDigest"], true), extraJSON) + if err != nil { + writeError(w, s.log, domain.Internal(err)) + return + } + + prefs, err := s.preferences(r.Context(), s.db.Pool, userID) + if err != nil { + writeError(w, s.log, err) + return + } + writeJSON(w, http.StatusOK, envelope{Data: prefs}) +} + +func truthy(v any, fallback bool) bool { + if b, ok := v.(bool); ok { + return b + } + return fallback +} diff --git a/go-api/internal/httpserver/ratelimit.go b/go-api/internal/httpserver/ratelimit.go new file mode 100644 index 0000000..b25322b --- /dev/null +++ b/go-api/internal/httpserver/ratelimit.go @@ -0,0 +1,154 @@ +package httpserver + +import ( + "net" + "net/http" + "strings" + "sync" + "time" +) + +// Login rate limiting. +// +// WHAT THIS IS: a fixed-window counter of *failed* sign-in attempts, held in +// this process's memory, keyed by client address and by email address. It turns +// online password guessing from "as fast as the server can hash" into a handful +// of tries per window, which is the whole job. +// +// WHAT THIS IS NOT, and the limitation to carry into production: +// +// - It is per-process. Two API instances behind a load balancer each allow +// the full budget, so the effective limit is the limit times the instance +// count, and a restart clears every counter. A deployment with more than +// one instance needs shared state — Redis, or the database — and this +// package is the seam where that goes: attemptLimiter is an implementation +// detail behind Allow/Fail/Reset. +// - It trusts net/http's RemoteAddr for the client address. Behind a reverse +// proxy every request appears to come from the proxy, so the per-address +// budget becomes global. Reading X-Forwarded-For instead would be worse, +// not better, until there is a trusted-proxy list to validate it against — +// a client can send that header itself and mint a fresh budget per request. +// Deploying behind a proxy means adding that list first. +// - It is memory-bounded by pruning, not by a hard cap, so a flood from many +// distinct addresses grows the map until the next prune. +// +// Only failures are counted. A correct password resets the email's counter, so +// a person who mistypes twice and then succeeds is not left carrying a penalty. + +const ( + // loginAttemptLimit is per email address per window. Five is comfortably + // above human error and far below useful for guessing. + loginAttemptLimit = 5 + // loginAddressLimit is per client address per window. Higher than the + // per-email limit because one address legitimately covers a whole office + // behind NAT, where several people may each fumble a password. + loginAddressLimit = 20 + // loginAttemptWindow is how long a counter lives. + loginAttemptWindow = 15 * time.Minute +) + +// attemptLimiter counts failures per key within a fixed window. +type attemptLimiter struct { + mu sync.Mutex + limit int + window time.Duration + now func() time.Time + buckets map[string]*attemptBucket +} + +type attemptBucket struct { + count int + resetAt time.Time +} + +func newAttemptLimiter(limit int, window time.Duration, now func() time.Time) *attemptLimiter { + if now == nil { + now = time.Now + } + return &attemptLimiter{ + limit: limit, window: window, now: now, + buckets: make(map[string]*attemptBucket), + } +} + +// Allow reports whether another attempt may be made, and if not, how long the +// caller should wait. It records nothing: only Fail does. +// +// Checking and recording are separate so a *successful* login never consumes +// budget — the check happens before the password is verified, and the recording +// only if it turns out to be wrong. +func (l *attemptLimiter) Allow(key string) (bool, time.Duration) { + if key == "" { + return true, 0 + } + l.mu.Lock() + defer l.mu.Unlock() + + b, ok := l.buckets[key] + now := l.now() + if !ok || !now.Before(b.resetAt) { + return true, 0 + } + if b.count < l.limit { + return true, 0 + } + return false, b.resetAt.Sub(now) +} + +// Fail records one failed attempt. +func (l *attemptLimiter) Fail(key string) { + if key == "" { + return + } + l.mu.Lock() + defer l.mu.Unlock() + + now := l.now() + l.pruneLocked(now) + + b, ok := l.buckets[key] + if !ok || !now.Before(b.resetAt) { + l.buckets[key] = &attemptBucket{count: 1, resetAt: now.Add(l.window)} + return + } + b.count++ +} + +// Reset clears a key's counter. Called on a successful sign-in. +func (l *attemptLimiter) Reset(key string) { + if key == "" { + return + } + l.mu.Lock() + defer l.mu.Unlock() + delete(l.buckets, key) +} + +// pruneMinimum is the size below which pruning is not worth the walk. +const pruneMinimum = 1024 + +// pruneLocked drops expired buckets once the map is large enough to be worth +// walking. Called from Fail, which is the only path that grows the map. +func (l *attemptLimiter) pruneLocked(now time.Time) { + if len(l.buckets) < pruneMinimum { + return + } + for key, b := range l.buckets { + if !now.Before(b.resetAt) { + delete(l.buckets, key) + } + } +} + +// clientAddr is the key for per-address limiting. +// +// The port is stripped: a browser uses a new source port for every connection, +// so keying on host:port would give each attempt its own budget and limit +// nothing at all. +func clientAddr(r *http.Request) string { + host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) + if err != nil { + return strings.TrimSpace(r.RemoteAddr) + } + return host +} diff --git a/go-api/internal/httpserver/rbac_test.go b/go-api/internal/httpserver/rbac_test.go new file mode 100644 index 0000000..e25a58d --- /dev/null +++ b/go-api/internal/httpserver/rbac_test.go @@ -0,0 +1,730 @@ +package httpserver_test + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/krow/krow-backend/go-api/internal/httpserver" +) + +// Phase 3D authorization tests. +// +// Two questions are under test and they are deliberately kept apart, because +// conflating them is how authorization bugs hide: +// +// MAY THIS ROLE CALL THIS ENDPOINT AT ALL? → checked in the handler, 403. +// WHICH ROWS DOES THIS CALLER SEE? → a SQL predicate, so a row that +// is not theirs is absent, 404. +// +// The row question is tested against the database rather than against a mock, +// because the answer lives in a WHERE clause. A test that stubbed the +// repository would prove the policy table is well-formed and nothing about +// whether talent B can read talent A's application. + +/* ── Fixture ────────────────────────────────────────────────────────────── */ + +// rbac is one organization holding one of each role, a second employer and a +// second talent to test isolation between peers, and a user in another +// organization entirely. +type rbac struct { + *api + admin, empA, empB, talA, talB actor + + otherOrgID string + outsider actor // admin in another organization + + activePosting string + draftPosting string +} + +func newRBAC(t *testing.T) *rbac { + t.Helper() + a := newAPI(t) // signs in as the seeded user, whose role is admin + ctx := context.Background() + r := &rbac{api: a} + + r.admin = actor{name: "admin", id: a.userID, email: a.email, role: "admin", cookie: a.cookie} + r.empA = signInAs(t, a.handler, a.h.Pool, a.orgID, "employerA", "employer-a@example.test", "employer") + r.empB = signInAs(t, a.handler, a.h.Pool, a.orgID, "employerB", "employer-b@example.test", "employer") + r.talA = signInAs(t, a.handler, a.h.Pool, a.orgID, "talentA", "talent-a@example.test", "talent") + r.talB = signInAs(t, a.handler, a.h.Pool, a.orgID, "talentB", "talent-b@example.test", "talent") + + if err := a.h.Pool.QueryRow(ctx, + `INSERT INTO organizations (name, slug) VALUES ('Other Tenant','other-tenant') RETURNING id::text`). + Scan(&r.otherOrgID); err != nil { + t.Fatalf("create the second organization: %v", err) + } + // An ADMIN in the other organization: cross-organization isolation must + // hold on its own, without a role restriction doing the work for it. + r.outsider = signInAs(t, a.handler, a.h.Pool, r.otherOrgID, "outsider", "outsider@example.test", "admin") + + // One active posting and one draft, for the talent visibility rule. + r.activePosting = createPosting(t, r, "Open Role", "active") + r.draftPosting = createPosting(t, r, "Unannounced Role", "draft") + return r +} + +func createPosting(t *testing.T, r *rbac, title, status string) string { + t.Helper() + got := r.as(r.admin, "POST", "/api/v1/job-postings", map[string]any{ + "title": title, "status": status, + }) + if got.code != http.StatusCreated { + t.Fatalf("create %s posting: %d (%v)", status, got.code, got.body) + } + return got.body["data"].(map[string]any)["id"].(string) +} + +func (r *rbac) ids(t *testing.T, act actor, path string) map[string]bool { + t.Helper() + got := r.as(act, "GET", path, nil) + if got.code != http.StatusOK { + t.Fatalf("%s GET %s = %d (%v)", act.name, path, got.code, got.body) + } + out := map[string]bool{} + for _, rec := range got.records(t) { + if id, ok := rec["id"].(string); ok { + out[id] = true + } + } + return out +} + +/* ── 1. The role matrix ─────────────────────────────────────────────────── */ + +// Every endpoint against every role. The assertion is only about the role gate: +// 403 means refused, anything else means the gate let the request through to be +// judged on its merits. A 422 from a deliberately thin body still proves the +// caller was allowed in, which is what this test is about. +func TestRoleMatrix(t *testing.T) { + r := newRBAC(t) + + type call struct { + method, path string + body any + } + // forbidden lists the roles that must be refused. Every other role must get + // past the gate. + cases := []struct { + call + forbidden []string + }{ + {call{"GET", "/api/v1/job-postings", nil}, nil}, + {call{"GET", "/api/v1/job-postings/" + r.activePosting, nil}, nil}, + {call{"POST", "/api/v1/job-postings", map[string]any{"title": "X"}}, []string{"talent"}}, + {call{"PATCH", "/api/v1/job-postings/" + r.activePosting, map[string]any{"location": "Here"}}, []string{"talent"}}, + + {call{"GET", "/api/v1/job-applications", nil}, nil}, + {call{"POST", "/api/v1/job-applications", map[string]any{ + "job_posting_id": r.activePosting, "applicant_name": "A", "email": "someone@example.test"}}, nil}, + {call{"PATCH", "/api/v1/job-applications/" + zeroUUID, map[string]any{"phone": "1"}}, []string{"talent"}}, + {call{"DELETE", "/api/v1/job-applications/" + zeroUUID, nil}, []string{"talent"}}, + + {call{"GET", "/api/v1/ai-interviews", nil}, nil}, + {call{"POST", "/api/v1/ai-interviews", map[string]any{ + "application_id": zeroUUID, "job_posting_id": r.activePosting}}, nil}, + + {call{"GET", "/api/v1/staff", nil}, []string{"talent"}}, + {call{"POST", "/api/v1/staff", map[string]any{ + "name": "N", "email": "s@example.test", "hire_date": "2026-01-01"}}, []string{"talent"}}, + {call{"PATCH", "/api/v1/staff/" + zeroUUID, map[string]any{"phone": "1"}}, []string{"talent"}}, + + {call{"GET", "/api/v1/worker-profiles", nil}, nil}, + {call{"POST", "/api/v1/worker-profiles", map[string]any{ + "full_name": "W", "email": "w@example.test"}}, nil}, + {call{"PATCH", "/api/v1/worker-profiles/" + zeroUUID, map[string]any{"phone": "1"}}, nil}, + + {call{"GET", "/api/v1/assignments", nil}, nil}, + {call{"POST", "/api/v1/assignments", map[string]any{ + "job_posting_id": r.activePosting, "worker_email": "w@example.test", + "starts_at": "2026-01-01T00:00:00.000Z"}}, []string{"talent"}}, + + {call{"GET", "/api/v1/shift-records", nil}, nil}, + + {call{"GET", "/api/v1/courses", nil}, nil}, + {call{"POST", "/api/v1/courses", map[string]any{"title": "C"}}, []string{"employer", "talent"}}, + {call{"PATCH", "/api/v1/courses/" + zeroUUID, map[string]any{"title": "C2"}}, []string{"employer", "talent"}}, + + {call{"GET", "/api/v1/learning-paths", nil}, nil}, + + {call{"GET", "/api/v1/role-categories", nil}, nil}, + {call{"POST", "/api/v1/role-categories", map[string]any{"name": "RC"}}, []string{"talent"}}, + + {call{"GET", "/api/v1/certifications", nil}, nil}, + {call{"POST", "/api/v1/certifications", map[string]any{"name": "Cert"}}, []string{"talent"}}, + {call{"DELETE", "/api/v1/certifications/" + zeroUUID, nil}, []string{"employer", "talent"}}, + + {call{"GET", "/api/v1/user-activity", nil}, nil}, + {call{"POST", "/api/v1/user-activity", map[string]any{"event_type": "test"}}, nil}, + + {call{"GET", "/api/v1/evidence", nil}, nil}, + {call{"POST", "/api/v1/evidence", map[string]any{"type": "photo_identify", "worker_email": "w@example.test"}}, nil}, + {call{"PATCH", "/api/v1/evidence/" + zeroUUID, map[string]any{"notes": "n"}}, []string{"talent"}}, + + // /me is every authenticated role's own business. + {call{"GET", "/api/v1/me", nil}, nil}, + {call{"PATCH", "/api/v1/me", map[string]any{"full_name": "Renamed"}}, nil}, + {call{"GET", "/api/v1/me/preferences", nil}, nil}, + {call{"PATCH", "/api/v1/me/preferences", map[string]any{"emailDigest": true}}, nil}, + } + + actors := map[string]actor{"admin": r.admin, "employer": r.empA, "talent": r.talA} + + for _, tc := range cases { + for role, act := range actors { + name := fmt.Sprintf("%s %s as %s", tc.method, tc.path, role) + t.Run(name, func(t *testing.T) { + got := r.as(act, tc.method, tc.path, tc.body) + denied := listsRole(tc.forbidden, role) + + if denied { + if got.code != http.StatusForbidden { + t.Errorf("= %d (%s), want 403 forbidden", got.code, got.codeOrEmpty()) + } + return + } + if got.code == http.StatusForbidden { + t.Errorf("= 403, but %s should be allowed through the role gate", role) + } + if got.code == http.StatusUnauthorized { + t.Errorf("= 401 — the session was rejected, which is not what this tests") + } + }) + } + } +} + +const zeroUUID = "00000000-0000-0000-0000-000000000000" + +func listsRole(set []string, v string) bool { + for _, s := range set { + if s == v { + return true + } + } + return false +} + +/* ── 2. Ownership isolation between two talent users ────────────────────── */ + +// Talent A's records are invisible to talent B across every owned resource, +// and visible to the organization's operators. +func TestTalentSeesOnlyTheirOwnRecords(t *testing.T) { + r := newRBAC(t) + ctx := context.Background() + + own := map[string]string{} // resource path → the id talent A owns + + // Created through the API by talent A, so the ownership column is whatever + // the server derived — not what the test asked for. + own["worker-profiles"] = mustCreate(t, r, r.talA, "/api/v1/worker-profiles", + map[string]any{"full_name": "Talent A", "email": r.talA.email}) + own["job-applications"] = mustCreate(t, r, r.talA, "/api/v1/job-applications", + map[string]any{"job_posting_id": r.activePosting, "applicant_name": "Talent A"}) + own["evidence"] = mustCreate(t, r, r.talA, "/api/v1/evidence", + map[string]any{"type": "photo_identify"}) + own["user-activity"] = mustCreate(t, r, r.talA, "/api/v1/user-activity", + map[string]any{"event_type": "viewed_something"}) + own["ai-interviews"] = mustCreate(t, r, r.talA, "/api/v1/ai-interviews", + map[string]any{"application_id": own["job-applications"], "job_posting_id": r.activePosting}) + + // Assignments are created by operators; shift records only by the seeder. + own["assignments"] = mustCreate(t, r, r.admin, "/api/v1/assignments", map[string]any{ + "job_posting_id": r.activePosting, "worker_email": r.talA.email, + "starts_at": "2026-01-01T00:00:00.000Z"}) + var shiftID string + if err := r.h.Pool.QueryRow(ctx, + `INSERT INTO shift_records + (org_id, worker_email, shift_date, scheduled_start, scheduled_end, scheduled_hours, created_date) + VALUES ($1::uuid, $2::citext, '2026-01-02', + '2026-01-02T09:00:00Z', '2026-01-02T17:00:00Z', 8, now()) + RETURNING id::text`, r.orgID, r.talA.email).Scan(&shiftID); err != nil { + t.Fatalf("insert a shift record: %v", err) + } + own["shift-records"] = shiftID + + // Talent B also has records of their own, so "B sees nothing" cannot pass + // by the endpoint simply being broken. + mustCreate(t, r, r.talB, "/api/v1/worker-profiles", + map[string]any{"full_name": "Talent B", "email": r.talB.email}) + mustCreate(t, r, r.talB, "/api/v1/user-activity", map[string]any{"event_type": "b_event"}) + + for path, id := range own { + t.Run(path, func(t *testing.T) { + if !r.ids(t, r.talA, "/api/v1/"+path+"?limit=500")[id] { + t.Errorf("talent A cannot see their own %s record", path) + } + if r.ids(t, r.talB, "/api/v1/"+path+"?limit=500")[id] { + t.Errorf("talent B can see talent A's %s record", path) + } + if !r.ids(t, r.admin, "/api/v1/"+path+"?limit=500")[id] { + t.Errorf("the organization's admin cannot see the %s record", path) + } + if !r.ids(t, r.empA, "/api/v1/"+path+"?limit=500")[id] { + t.Errorf("the organization's employer cannot see the %s record", path) + } + }) + } + + // The count must respect ownership too. A total computed over the whole + // organization would leak how many records exist even with the rows hidden. + t.Run("meta total respects ownership", func(t *testing.T) { + got := r.as(r.talB, "GET", "/api/v1/worker-profiles?limit=500", nil) + meta := got.meta(t) + if n, _ := meta["total"].(float64); n != 1 { + t.Errorf("talent B's worker-profiles total = %v, want 1 (their own)", meta["total"]) + } + }) + + // Talent A cannot reach talent B's profile by PATCHing its id either: the + // ownership predicate is in the UPDATE's WHERE clause, so the row is not + // found rather than refused. + t.Run("PATCH another talent's profile is 404", func(t *testing.T) { + var bProfile string + if err := r.h.Pool.QueryRow(ctx, + `SELECT id::text FROM worker_profiles WHERE user_id = $1::uuid`, r.talB.id).Scan(&bProfile); err != nil { + t.Fatalf("find talent B's profile: %v", err) + } + got := r.as(r.talA, "PATCH", "/api/v1/worker-profiles/"+bProfile, map[string]any{"phone": "hijacked"}) + if got.code != http.StatusNotFound { + t.Errorf("= %d, want 404 (absent, not forbidden — existence must not leak)", got.code) + } + var phone string + if err := r.h.Pool.QueryRow(ctx, + `SELECT phone FROM worker_profiles WHERE id = $1::uuid`, bProfile).Scan(&phone); err != nil { + t.Fatalf("re-read talent B's profile: %v", err) + } + if phone == "hijacked" { + t.Fatal("talent A modified talent B's worker profile") + } + }) +} + +func mustCreate(t *testing.T, r *rbac, act actor, path string, body map[string]any) string { + t.Helper() + got := r.as(act, "POST", path, body) + if got.code != http.StatusCreated { + t.Fatalf("%s POST %s = %d (%v)", act.name, path, got.code, got.body) + } + return got.body["data"].(map[string]any)["id"].(string) +} + +/* ── 3. Mass assignment ─────────────────────────────────────────────────── */ + +// Identity a caller supplies is ignored; identity the server derives wins. +// +// This is the test that makes the ownership predicates above mean anything. If +// a talent user could name someone else in the ownership column, every "own +// records only" rule would be bypassable by the same request it constrains. +func TestServerOwnedIdentityCannotBeSupplied(t *testing.T) { + r := newRBAC(t) + ctx := context.Background() + + t.Run("worker_profiles.user_id", func(t *testing.T) { + id := mustCreate(t, r, r.talA, "/api/v1/worker-profiles", map[string]any{ + "full_name": "Claimed", "email": r.talA.email, + "user_id": r.talB.id, // naming somebody else + }) + var owner string + if err := r.h.Pool.QueryRow(ctx, + `SELECT COALESCE(user_id::text,'') FROM worker_profiles WHERE id = $1::uuid`, id).Scan(&owner); err != nil { + t.Fatalf("read the profile: %v", err) + } + if owner != r.talA.id { + t.Errorf("user_id = %q, want the creating talent %q", owner, r.talA.id) + } + }) + + t.Run("worker_profiles.user_id is NOT the admin when an operator creates one", func(t *testing.T) { + // The subject of an operator-created profile is a candidate, not the + // operator. Deriving it unconditionally would file every candidate's + // record under whoever typed it in. + id := mustCreate(t, r, r.admin, "/api/v1/worker-profiles", map[string]any{ + "full_name": "Candidate", "email": "candidate@example.test", + }) + var owner string + if err := r.h.Pool.QueryRow(ctx, + `SELECT COALESCE(user_id::text,'') FROM worker_profiles WHERE id = $1::uuid`, id).Scan(&owner); err != nil { + t.Fatalf("read the profile: %v", err) + } + if owner != "" { + t.Errorf("user_id = %q, want empty — an operator-created profile has no claimant yet", owner) + } + }) + + t.Run("job_applications.email", func(t *testing.T) { + id := mustCreate(t, r, r.talA, "/api/v1/job-applications", map[string]any{ + "job_posting_id": r.activePosting, "applicant_name": "A", + "email": r.talB.email, // applying as somebody else + }) + var email string + if err := r.h.Pool.QueryRow(ctx, + `SELECT email::text FROM job_applications WHERE id = $1::uuid`, id).Scan(&email); err != nil { + t.Fatalf("read the application: %v", err) + } + if email != r.talA.email { + t.Errorf("email = %q, want the applying talent %q", email, r.talA.email) + } + }) + + t.Run("evidence.worker_email", func(t *testing.T) { + id := mustCreate(t, r, r.talA, "/api/v1/evidence", map[string]any{ + "type": "photo_identify", "worker_email": r.talB.email, + }) + var email string + if err := r.h.Pool.QueryRow(ctx, + `SELECT worker_email::text FROM evidence WHERE id = $1::uuid`, id).Scan(&email); err != nil { + t.Fatalf("read the evidence: %v", err) + } + if email != r.talA.email { + t.Errorf("worker_email = %q, want %q", email, r.talA.email) + } + }) + + t.Run("user_activity identity is entirely server-derived", func(t *testing.T) { + id := mustCreate(t, r, r.talA, "/api/v1/user-activity", map[string]any{ + "event_type": "forged", + "user_id": r.admin.id, + "user_email": r.admin.email, + "user_name": "The Administrator", + "account_type": "admin", + }) + var uid, email, name, acct string + if err := r.h.Pool.QueryRow(ctx, + `SELECT COALESCE(user_id::text,''), user_email::text, user_name, account_type + FROM user_activity WHERE id::text = $1`, id).Scan(&uid, &email, &name, &acct); err != nil { + t.Fatalf("read the activity row: %v", err) + } + if uid != r.talA.id || email != r.talA.email { + t.Errorf("activity attributed to %s/%s, want talent A %s/%s", uid, email, r.talA.id, r.talA.email) + } + if name == "The Administrator" || acct == "admin" { + t.Errorf("client-supplied user_name/account_type were stored: %q / %q", name, acct) + } + }) + + t.Run("job_postings.created_by", func(t *testing.T) { + got := r.as(r.empA, "POST", "/api/v1/job-postings", map[string]any{ + "title": "Attributed", "created_by": r.admin.id, + }) + if got.code != http.StatusCreated { + t.Fatalf("create = %d (%v)", got.code, got.body) + } + id := got.body["data"].(map[string]any)["id"].(string) + var by string + if err := r.h.Pool.QueryRow(ctx, + `SELECT COALESCE(created_by::text,'') FROM job_postings WHERE id = $1::uuid`, id).Scan(&by); err != nil { + t.Fatalf("read the posting: %v", err) + } + if by != r.empA.id { + t.Errorf("created_by = %q, want the actual creator %q", by, r.empA.id) + } + }) + + t.Run("org_id and role still cannot be supplied", func(t *testing.T) { + id := mustCreate(t, r, r.empA, "/api/v1/job-postings", map[string]any{ + "title": "Tenancy", "org_id": r.otherOrgID, + }) + var org string + if err := r.h.Pool.QueryRow(ctx, + `SELECT org_id::text FROM job_postings WHERE id = $1::uuid`, id).Scan(&org); err != nil { + t.Fatalf("read the posting: %v", err) + } + if org != r.orgID { + t.Errorf("org_id = %q, want the session's organization %q", org, r.orgID) + } + + // And a talent cannot promote themselves through /me. + if got := r.as(r.talA, "PATCH", "/api/v1/me", map[string]any{"role": "admin"}); got.code != http.StatusOK { + t.Fatalf("PATCH /me = %d", got.code) + } + var role string + if err := r.h.Pool.QueryRow(ctx, `SELECT role FROM users WHERE id = $1::uuid`, r.talA.id).Scan(&role); err != nil { + t.Fatalf("read the user: %v", err) + } + if role != "talent" { + t.Fatalf("role = %q — a talent user promoted themselves", role) + } + }) +} + +// A talent user cannot attach an interview to somebody else's application. +// Ownership here is by reference, so it is checked against the application. +func TestTalentCannotInterviewForAnotherApplication(t *testing.T) { + r := newRBAC(t) + + othersApplication := mustCreate(t, r, r.talB, "/api/v1/job-applications", + map[string]any{"job_posting_id": r.activePosting, "applicant_name": "Talent B"}) + + got := r.as(r.talA, "POST", "/api/v1/ai-interviews", map[string]any{ + "application_id": othersApplication, "job_posting_id": r.activePosting, + }) + if got.code != http.StatusNotFound { + t.Errorf("= %d (%s), want 404 — the same answer an application that does not exist gives", + got.code, got.codeOrEmpty()) + } + + // Their own application is accepted, so the guard is not simply refusing + // everything. + mine := mustCreate(t, r, r.talA, "/api/v1/job-applications", + map[string]any{"job_posting_id": r.activePosting, "applicant_name": "Talent A"}) + if ok := r.as(r.talA, "POST", "/api/v1/ai-interviews", map[string]any{ + "application_id": mine, "job_posting_id": r.activePosting, + }); ok.code != http.StatusCreated { + t.Errorf("interviewing for their own application = %d (%v)", ok.code, ok.body) + } +} + +/* ── 4. Talent posting visibility ───────────────────────────────────────── */ + +func TestTalentSeesOnlyActivePostings(t *testing.T) { + r := newRBAC(t) + + talent := r.ids(t, r.talA, "/api/v1/job-postings?limit=200") + if !talent[r.activePosting] { + t.Error("talent cannot see an active posting") + } + if talent[r.draftPosting] { + t.Error("talent can see a draft posting") + } + + for _, act := range []actor{r.admin, r.empA} { + seen := r.ids(t, act, "/api/v1/job-postings?limit=200") + if !seen[r.draftPosting] { + t.Errorf("%s cannot see the organization's draft posting", act.name) + } + } + + // By id, too — and as a 404, so the draft's existence is not disclosed. + if got := r.as(r.talA, "GET", "/api/v1/job-postings/"+r.draftPosting, nil); got.code != http.StatusNotFound { + t.Errorf("talent GET of a draft posting = %d, want 404", got.code) + } + if got := r.as(r.talA, "GET", "/api/v1/job-postings/"+r.activePosting, nil); got.code != http.StatusOK { + t.Errorf("talent GET of an active posting = %d, want 200", got.code) + } +} + +/* ── 5. Cross-organization isolation ────────────────────────────────────── */ + +// The outsider is an ADMIN in another organization, so nothing here is being +// done by a role restriction. +func TestCrossOrganizationIsolation(t *testing.T) { + r := newRBAC(t) + ctx := context.Background() + + appID := mustCreate(t, r, r.admin, "/api/v1/job-applications", map[string]any{ + "job_posting_id": r.activePosting, "applicant_name": "Insider", "email": "insider@example.test"}) + + t.Run("cannot read", func(t *testing.T) { + if r.ids(t, r.outsider, "/api/v1/job-postings?limit=200")[r.activePosting] { + t.Error("an outsider can list another organization's posting") + } + if got := r.as(r.outsider, "GET", "/api/v1/job-postings/"+r.activePosting, nil); got.code != http.StatusNotFound { + t.Errorf("GET by id = %d, want 404", got.code) + } + if n := len(r.ids(t, r.outsider, "/api/v1/job-applications?limit=200")); n != 0 { + t.Errorf("an outsider sees %d applications from another organization", n) + } + }) + + t.Run("cannot update", func(t *testing.T) { + got := r.as(r.outsider, "PATCH", "/api/v1/job-postings/"+r.activePosting, + map[string]any{"title": "Hijacked"}) + if got.code != http.StatusNotFound { + t.Errorf("= %d, want 404", got.code) + } + var title string + if err := r.h.Pool.QueryRow(ctx, `SELECT title FROM job_postings WHERE id = $1::uuid`, + r.activePosting).Scan(&title); err != nil { + t.Fatalf("re-read: %v", err) + } + if title == "Hijacked" { + t.Fatal("an outsider modified another organization's posting") + } + }) + + t.Run("cannot delete", func(t *testing.T) { + // DELETE reports success whether or not a row matched — a deliberate + // contract choice (§12.7) that reveals nothing. What matters is that + // the row survives. + r.as(r.outsider, "DELETE", "/api/v1/job-applications/"+appID, nil) + var alive int + if err := r.h.Pool.QueryRow(ctx, + `SELECT count(*)::int FROM job_applications WHERE id = $1::uuid`, appID).Scan(&alive); err != nil { + t.Fatalf("count: %v", err) + } + if alive != 1 { + t.Fatal("an outsider deleted another organization's application") + } + }) +} + +/* ── 6. 403 versus 404 ──────────────────────────────────────────────────── */ + +// The discipline: a refused ROLE is 403; a row outside the caller's visibility +// is 404, whether it is another tenant's or another person's. +func TestForbiddenVersusNotFound(t *testing.T) { + r := newRBAC(t) + + t.Run("role refused is 403", func(t *testing.T) { + got := r.as(r.talA, "GET", "/api/v1/staff", nil) + if got.code != http.StatusForbidden || got.codeOrEmpty() != "forbidden" { + t.Errorf("= %d (%s), want 403 forbidden", got.code, got.codeOrEmpty()) + } + // And the message must not name the roles that would have worked. + body, _ := got.body["error"].(map[string]any) + msg, _ := body["message"].(string) + for _, leak := range []string{"admin", "employer", "talent", "role"} { + if containsFold(msg, leak) { + t.Errorf("the 403 message names %q: %q", leak, msg) + } + } + }) + + t.Run("another tenant's row is 404", func(t *testing.T) { + if got := r.as(r.outsider, "GET", "/api/v1/job-postings/"+r.activePosting, nil); got.code != http.StatusNotFound { + t.Errorf("= %d, want 404", got.code) + } + }) + + t.Run("another person's row is 404", func(t *testing.T) { + bProfile := mustCreate(t, r, r.talB, "/api/v1/worker-profiles", + map[string]any{"full_name": "B", "email": r.talB.email}) + if got := r.as(r.talA, "PATCH", "/api/v1/worker-profiles/"+bProfile, + map[string]any{"phone": "x"}); got.code != http.StatusNotFound { + t.Errorf("= %d, want 404", got.code) + } + }) + + t.Run("unauthenticated is still 401", func(t *testing.T) { + if got := r.doAnon("GET", "/api/v1/staff", nil); got.code != http.StatusUnauthorized { + t.Errorf("= %d, want 401", got.code) + } + }) +} + +func containsFold(haystack, needle string) bool { + h, n := []rune(haystack), []rune(needle) + lower := func(r rune) rune { + if r >= 'A' && r <= 'Z' { + return r + 32 + } + return r + } + for i := 0; i+len(n) <= len(h); i++ { + ok := true + for j := range n { + if lower(h[i+j]) != lower(n[j]) { + ok = false + break + } + } + if ok { + return true + } + } + return false +} + +/* ── 7. Admin regression ────────────────────────────────────────────────── */ + +// Everything the admin console does today must still work. The endpoints below +// are the ones the frontend actually calls, taken from the Phase 3D audit's +// call-site inventory. +func TestAdminRegression(t *testing.T) { + r := newRBAC(t) + + for _, path := range []string{ + "job-postings", "job-applications", "ai-interviews", "staff", "worker-profiles", + "courses", "learning-paths", "certifications", "role-categories", + "user-activity", "evidence", "assignments", "shift-records", + } { + if got := r.as(r.admin, "GET", "/api/v1/"+path+"?limit=5", nil); got.code != http.StatusOK { + t.Errorf("admin GET /api/v1/%s = %d (%v)", path, got.code, got.body) + } + } + + // The seeded dataset is still fully visible to an admin: ownership scoping + // must not have narrowed the operator view. + if n := len(r.ids(t, r.admin, "/api/v1/job-postings?limit=200")); n < 8 { + t.Errorf("admin sees %d job postings, want at least the 8 seeded", n) + } + + // A representative write of each shape. + posting := mustCreate(t, r, r.admin, "/api/v1/job-postings", map[string]any{"title": "Admin Wrote This"}) + if got := r.as(r.admin, "PATCH", "/api/v1/job-postings/"+posting, + map[string]any{"location": "Somewhere"}); got.code != http.StatusOK { + t.Errorf("admin PATCH = %d (%v)", got.code, got.body) + } + app := mustCreate(t, r, r.admin, "/api/v1/job-applications", map[string]any{ + "job_posting_id": posting, "applicant_name": "C", "email": "c@example.test"}) + if got := r.as(r.admin, "DELETE", "/api/v1/job-applications/"+app, nil); got.code != http.StatusOK { + t.Errorf("admin DELETE = %d", got.code) + } + if got := r.as(r.admin, "GET", "/api/v1/me", nil); got.code != http.StatusOK { + t.Errorf("admin GET /me = %d", got.code) + } + if got := r.doAnon("GET", "/health", nil); got.code != http.StatusOK { + t.Errorf("GET /health = %d, want 200 and still public", got.code) + } +} + +/* ── 8. Employer boundaries ─────────────────────────────────────────────── */ + +func TestEmployerBoundaries(t *testing.T) { + r := newRBAC(t) + + // Employer runs the organization's hiring: the operator surface works. + for _, path := range []string{"job-postings", "job-applications", "staff", "worker-profiles", "user-activity"} { + if got := r.as(r.empA, "GET", "/api/v1/"+path+"?limit=5", nil); got.code != http.StatusOK { + t.Errorf("employer GET /api/v1/%s = %d", path, got.code) + } + } + + // Admin-only operations are refused. Course authoring is admin's because a + // NULL-org course is the shared platform library and reaches every tenant. + for _, tc := range []struct{ method, path string }{ + {"POST", "/api/v1/courses"}, + {"PATCH", "/api/v1/courses/" + zeroUUID}, + {"DELETE", "/api/v1/certifications/" + zeroUUID}, + } { + got := r.as(r.empA, tc.method, tc.path, map[string]any{"title": "X"}) + if got.code != http.StatusForbidden { + t.Errorf("employer %s %s = %d, want 403", tc.method, tc.path, got.code) + } + } + + // Two employers in one organization see the same rows: the ownership + // predicate must not have leaked onto the operator roles. + posting := mustCreate(t, r, r.empA, "/api/v1/job-postings", map[string]any{"title": "By A"}) + if !r.ids(t, r.empB, "/api/v1/job-postings?limit=200")[posting] { + t.Error("employer B cannot see employer A's posting — operators share the organization") + } + if got := r.as(r.empB, "PATCH", "/api/v1/job-postings/"+posting, + map[string]any{"location": "Edited by B"}); got.code != http.StatusOK { + t.Errorf("employer B editing employer A's posting = %d, want 200", got.code) + } +} + +/* ── 9. Session expiry still governs everything ─────────────────────────── */ + +// Authorization does not replace authentication: an expired session is refused +// before any role is consulted. +func TestExpiredSessionIsRefusedBeforeRoleCheck(t *testing.T) { + now := time.Date(2026, 8, 22, 9, 0, 0, 0, time.UTC) + a := newAPI(t, + httpserver.WithClock(func() time.Time { return now }), + httpserver.WithSessionPolicy(shortSessions)) + + if got := a.do("GET", "/api/v1/job-postings", nil); got.code != http.StatusOK { + t.Fatalf("while live = %d", got.code) + } + now = now.Add(shortSessions.IdleLifetime + time.Minute) + got := a.do("GET", "/api/v1/job-postings", nil) + if got.code != http.StatusUnauthorized { + t.Errorf("= %d (%s), want 401 — not 403", got.code, got.codeOrEmpty()) + } +} diff --git a/go-api/internal/httpserver/response.go b/go-api/internal/httpserver/response.go new file mode 100644 index 0000000..02d19be --- /dev/null +++ b/go-api/internal/httpserver/response.go @@ -0,0 +1,110 @@ +package httpserver + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + + "github.com/krow/krow-backend/go-api/internal/domain" +) + +// envelope is the success shape from api-contract.md §4. +type envelope struct { + Data any `json:"data"` + Meta *meta `json:"meta,omitempty"` +} + +// meta accompanies a collection. `truncated` exists so the silent-truncation +// problem in §12.2 is fixable without another contract change. +type meta struct { + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Returned int `json:"returned"` + Truncated bool `json:"truncated"` +} + +// errorEnvelope is the failure shape from api-contract.md §5. +type errorEnvelope struct { + Error errorBody `json:"error"` +} + +type errorBody struct { + Code string `json:"code"` + Message string `json:"message"` + Details map[string]string `json:"details"` +} + +func writeJSON(w http.ResponseWriter, code int, body any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(code) + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(body) +} + +func writeRecord(w http.ResponseWriter, code int, rec domain.Record) { + writeJSON(w, code, envelope{Data: rec}) +} + +func writePage(w http.ResponseWriter, page *domain.Page) { + writeJSON(w, http.StatusOK, envelope{ + Data: page.Records, + Meta: &meta{ + Total: page.Total, + Limit: page.Limit, + Offset: page.Offset, + Returned: len(page.Records), + Truncated: page.Total > page.Offset+len(page.Records), + }, + }) +} + +// statusFor maps the contract's error codes onto HTTP status codes. +func statusFor(code string) int { + switch code { + case "unauthorized": + return http.StatusUnauthorized + case "forbidden": + return http.StatusForbidden + case "rate_limited": + return http.StatusTooManyRequests + case "not_found": + return http.StatusNotFound + case "validation_failed": + return http.StatusUnprocessableEntity + case "invalid_query": + return http.StatusBadRequest + case "conflict": + return http.StatusConflict + default: + return http.StatusInternalServerError + } +} + +// writeError renders any error as the documented envelope. +// +// An unrecognised error is deliberately flattened to a generic message: the +// detail goes to the log, not to the client. +func writeError(w http.ResponseWriter, log *slog.Logger, err error) { + var de *domain.Error + if !errors.As(err, &de) { + log.Error("unhandled error", "error", err) + writeJSON(w, http.StatusInternalServerError, errorEnvelope{Error: errorBody{ + Code: "internal", Message: "internal error", Details: map[string]string{}, + }}) + return + } + if de.Code == "internal" { + log.Error("internal error", "error", de.Unwrap()) + } + details := de.Details + if details == nil { + details = map[string]string{} + } + writeJSON(w, statusFor(de.Code), errorEnvelope{Error: errorBody{ + Code: de.Code, Message: de.Message, Details: details, + }}) +} diff --git a/go-api/internal/httpserver/server.go b/go-api/internal/httpserver/server.go new file mode 100644 index 0000000..59e07a6 --- /dev/null +++ b/go-api/internal/httpserver/server.go @@ -0,0 +1,383 @@ +// Package httpserver holds the HTTP surface. +// +// It serves /health, the sign-in endpoints, the entity endpoints described in +// docs/api-contract.md, and the current-user endpoints. +// +// Phase 3C replaced the development identity with real authentication. Every +// request outside the small public allowlist in auth.go must carry a session +// cookie; the middleware resolves it to a user row and puts that user, and +// their organization, on the request context. Nothing downstream changed — +// every service and repository already took the organization as a parameter, +// which is what devOrgMiddleware existed to make true. +// +// Authorization is NOT here. A signed-in user reaches every endpoint they could +// reach before; deciding which roles may do what is Phase 3D. +package httpserver + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "strconv" + "time" + + "github.com/krow/krow-backend/go-api/internal/auth" + "github.com/krow/krow-backend/go-api/internal/config" + "github.com/krow/krow-backend/go-api/internal/db" + "github.com/krow/krow-backend/go-api/internal/service" +) + +// Server binds the router, the pool, authentication and the lifecycle together. +type Server struct { + cfg *config.Config + db *db.DB + api *service.Registry + definitions *service.DefinitionsService + log *slog.Logger + http *http.Server + started time.Time + endpoints int + + // The authentication surface. sessions owns the lifecycle, users is the + // read side of the users table, credentials verifies a password against it, + // and the two limiters bound how often that may be attempted. + // + // Two limiters, not one, because the budgets are different sizes on + // purpose: an email is one account and gets a tight budget, while an + // address may be a whole office behind NAT and gets a loose one. Sharing a + // limiter would force the office to live within one person's budget. + sessions *auth.Manager + users auth.UserStore + credentials *auth.Credentials + loginByEmail *attemptLimiter + loginByAddr *attemptLimiter + + // now is injectable so tests can drive expiry without sleeping. + now func() time.Time +} + +// Option adjusts the server before it is wired. Production passes none. +type Option func(*serverOptions) + +type serverOptions struct { + policy auth.Policy + now func() time.Time + perEmail int + perAddress int + loginWindow time.Duration +} + +// WithSessionPolicy overrides the session lifetimes. For tests that need to +// reach an expiry without waiting twelve hours for it. +func WithSessionPolicy(p auth.Policy) Option { + return func(o *serverOptions) { o.policy = p } +} + +// WithClock replaces the clock used for session expiry and last_login_at. +func WithClock(now func() time.Time) Option { + return func(o *serverOptions) { + if now != nil { + o.now = now + } + } +} + +// WithLoginRateLimit overrides the failed-attempt budgets and their window. +// +// perEmail bounds attempts against one account; perAddress bounds attempts from +// one client address across all accounts. Both are consulted on every attempt. +func WithLoginRateLimit(perEmail, perAddress int, window time.Duration) Option { + return func(o *serverOptions) { + o.perEmail, o.perAddress, o.loginWindow = perEmail, perAddress, window + } +} + +// New wires the routes and returns a server that has not yet been started. +// +// Authentication is built here rather than passed in, so there is exactly one +// construction of the session manager and no way to start a server with the +// middleware wired to a different store than the login handler. +func New(cfg *config.Config, database *db.DB, log *slog.Logger, opts ...Option) (*Server, error) { + o := serverOptions{ + policy: auth.DefaultPolicy, + now: time.Now, + perEmail: loginAttemptLimit, + perAddress: loginAddressLimit, + loginWindow: loginAttemptWindow, + } + for _, opt := range opts { + opt(&o) + } + + sessions, err := auth.NewManager(auth.NewPGStore(database.Pool), o.policy) + if err != nil { + return nil, fmt.Errorf("build session manager: %w", err) + } + sessions.WithClock(o.now) + + users := auth.NewPGUserStore(database.Pool) + s := &Server{ + cfg: cfg, db: database, log: log, + api: service.NewRegistry(database.Pool), + definitions: service.NewDefinitions(database.Pool), + started: o.now(), + sessions: sessions, + users: users, + credentials: auth.NewCredentials(users), + loginByEmail: newAttemptLimiter(o.perEmail, o.loginWindow, o.now), + loginByAddr: newAttemptLimiter(o.perAddress, o.loginWindow, o.now), + now: o.now, + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /health", s.handleHealth) + s.endpoints = s.routeAuth(mux) + s.routeResources(mux) + s.routeMe(mux) + s.routeDefinitions(mux) + + handler := jsonErrors(mux) + // Authentication sits where devOrgMiddleware used to, so every route below + // it — including the mux's own 404 — is behind the allowlist. + handler = s.authenticate(handler) + handler = recoverer(log)(handler) + // CORS sits outside the recoverer so a preflight is answered without + // touching the router, and inside the logger so refused origins are still + // visible in the log. With no allowlist configured it is not installed at + // all, which is the same-origin default. + if len(cfg.HTTP.CORSOrigins) > 0 { + handler = cors(cfg.HTTP.CORSOrigins)(handler) + } + handler = requestLogger(log)(handler) + + s.http = &http.Server{ + Addr: net.JoinHostPort(cfg.HTTP.Host, strconv.Itoa(cfg.HTTP.Port)), + Handler: handler, + ReadTimeout: cfg.HTTP.ReadTimeout, + WriteTimeout: cfg.HTTP.WriteTimeout, + IdleTimeout: cfg.HTTP.IdleTimeout, + } + return s, nil +} + +// Sessions exposes the session manager, so the process can sweep expired rows +// and tests can drive the clock. +func (s *Server) Sessions() *auth.Manager { return s.sessions } + +// Handler exposes the routed handler so tests can drive it without a listener. +func (s *Server) Handler() http.Handler { return s.http.Handler } + +// Endpoints is how many routes were registered. +func (s *Server) Endpoints() int { return s.endpoints } + +// Addr is the address the server listens on. +func (s *Server) Addr() string { return s.http.Addr } + +// Start blocks until the server stops accepting connections. +func (s *Server) Start() error { + err := s.http.ListenAndServe() + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} + +// Shutdown drains in-flight requests, then gives up after the configured grace. +func (s *Server) Shutdown(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, s.cfg.HTTP.ShutdownTimeout) + defer cancel() + return s.http.Shutdown(ctx) +} + +// healthResponse is the entire public /health body: one field, deliberately. +// +// /health is unauthenticated and reachable by anyone who can reach the port, +// so it is treated as a public document rather than as an operator's console. +// Everything an unauthenticated caller legitimately needs is the answer to +// "should traffic be sent here", and that fits in a status string plus the +// HTTP status code. +// +// What used to be here and is now deliberately absent: the PostgreSQL version, +// the database name, the schema name, the applied migration version, the table +// count, the connection error text, the deployment environment and the process +// uptime. Individually each is small; together they are a free reconnaissance +// report — the server version to look up known CVEs against, the migration +// version to date the deployment, the table count and error text to infer +// shape and topology. None of it is diagnostic to anyone who could not already +// read it from the database directly. +// +// The check itself is unchanged. db.Check still runs on every request and +// still decides the answer; its full detail now goes to the server log, where +// the operator is, instead of into the response, where the internet is. See +// logHealth. +type healthResponse struct { + Status string `json:"status"` +} + +// handleHealth reports whether this instance should be sent traffic. +// +// 200 "ok" serving normally +// 200 "degraded" the process is healthy, the schema is not: unmigrated, +// or a migration left the version dirty. Still 200, +// because the fault is the database's and taking the +// instance out of rotation would not fix it. +// 503 "unavailable" the database is unreachable, so a load balancer can act +// on the status code alone without parsing the body. +// +// The three status words are a coarse operational signal, not infrastructure +// detail: they say what a caller should do, and nothing about what is running. +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + health := s.db.Check(ctx) + + status, code := "ok", http.StatusOK + switch { + case !health.Reachable: + status, code = "unavailable", http.StatusServiceUnavailable + case health.MigrationDirty, !health.SchemaPresent: + status = "degraded" + } + + s.logHealth(status, health) + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(code) + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(healthResponse{Status: status}) +} + +// logHealth writes the detail the response body used to carry. +// +// This is the "internally" half of the change: nothing was deleted from +// db.Check, and nothing it learns is thrown away — the audience moved from the +// response to the log, which is already authenticated by virtue of being on +// the host. +// +// A load balancer polls this endpoint every few seconds, so a healthy check +// logs at debug and a bad one at warn. Anything other than "ok" is worth +// seeing without turning debug on. +func (s *Server) logHealth(status string, h db.Health) { + attrs := []any{ + "status", status, + "env", s.cfg.AppEnv, + "uptime_seconds", int64(time.Since(s.started).Seconds()), + "reachable", h.Reachable, + "schema", h.Schema, + "schema_present", h.SchemaPresent, + "table_count", h.TableCount, + "migration_dirty", h.MigrationDirty, + "latency_ms", h.LatencyMS, + } + if h.Database != "" { + attrs = append(attrs, "database", h.Database, "postgres_version", h.Version) + } + if h.AppliedMigration != nil { + attrs = append(attrs, "applied_migration", *h.AppliedMigration) + } + if h.Error != "" { + attrs = append(attrs, "error", h.Error) + } + + if status == "ok" { + s.log.Debug("health", attrs...) + return + } + s.log.Warn("health", attrs...) +} + +type statusRecorder struct { + http.ResponseWriter + code int +} + +func (r *statusRecorder) WriteHeader(code int) { + r.code = code + r.ResponseWriter.WriteHeader(code) +} + +func requestLogger(log *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + started := time.Now() + rec := &statusRecorder{ResponseWriter: w, code: http.StatusOK} + next.ServeHTTP(rec, r) + log.Info("request", + "method", r.Method, "path", r.URL.Path, + "status", rec.code, "duration_ms", time.Since(started).Milliseconds()) + }) + } +} + +// jsonErrors converts net/http's own plain-text 404 and 405 replies into the +// documented error envelope. +// +// ServeMux writes those itself, before any handler of ours runs, so a client +// that hit a wrong path or method would otherwise get "404 page not found" in +// text/plain while every other response is JSON. Only the mux's own replies are +// rewritten: anything that set a content type has already answered properly. +func jsonErrors(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + iw := &interceptor{ResponseWriter: w} + next.ServeHTTP(iw, r) + if !iw.rewritten || iw.wrote { + return + } + errCode, message := "not_found", "resource not found" + if iw.code == http.StatusMethodNotAllowed { + errCode = "method_not_allowed" + message = r.Method + " is not supported for this resource" + } + writeJSON(w, iw.code, errorEnvelope{Error: errorBody{ + Code: errCode, Message: message, Details: map[string]string{}, + }}) + }) +} + +// interceptor defers the mux's plain-text 404/405 body so it can be replaced. +type interceptor struct { + http.ResponseWriter + code int + rewritten bool // this is a mux-generated 404/405 we intend to replace + wrote bool // a body already went to the client +} + +func (i *interceptor) WriteHeader(code int) { + i.code = code + if code == http.StatusNotFound || code == http.StatusMethodNotAllowed { + if i.Header().Get("Content-Type") != "application/json; charset=utf-8" { + i.rewritten = true + return // hold the header back; jsonErrors writes its own + } + } + i.ResponseWriter.WriteHeader(code) +} + +func (i *interceptor) Write(b []byte) (int, error) { + if i.rewritten { + return len(b), nil // swallow the mux's plain-text body + } + i.wrote = true + return i.ResponseWriter.Write(b) +} + +// recoverer turns a panic into a logged 500 rather than a dropped connection. +func recoverer(log *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if v := recover(); v != nil { + log.Error("panic", "value", v, "path", r.URL.Path) + writeJSON(w, http.StatusInternalServerError, errorEnvelope{Error: errorBody{ + Code: "internal", Message: "internal error", Details: map[string]string{}, + }}) + } + }() + next.ServeHTTP(w, r) + }) + } +} diff --git a/go-api/internal/orgctx/orgctx.go b/go-api/internal/orgctx/orgctx.go new file mode 100644 index 0000000..067ea67 --- /dev/null +++ b/go-api/internal/orgctx/orgctx.go @@ -0,0 +1,52 @@ +// Package orgctx carries the organization a request operates on. +// +// Phase 2C has no authentication, so there is nothing to derive a tenant from. +// Rather than defaulting org_id deep inside the SQL — where it would have to be +// unpicked from fourteen repositories once auth arrives — the value is put on +// the request context by one middleware and threaded explicitly through the +// service and repository boundaries. +// +// When authentication lands, DevMiddleware is replaced by one that reads the +// organization off the authenticated session. Nothing below this package +// changes: every caller already takes an org id as a parameter. +package orgctx + +import ( + "context" + "errors" +) + +type key struct{} + +// DevOrgSlug identifies the single organization every Phase 2C request runs as. +// The seeder creates it; nothing else does. +// +// THIS IS NOT AUTHENTICATION. It is a fixed development identity with no +// credential, no session and no verification behind it. +const DevOrgSlug = "krow-dev" + +// DevOrgName is that organization's display name. +const DevOrgName = "Krow Development" + +// ErrNoOrg means the context reached a scoped operation without an organization, +// which is a programming error rather than a client one. +var ErrNoOrg = errors.New("no organization in context") + +// With returns a context carrying an organization id. +func With(ctx context.Context, orgID string) context.Context { + return context.WithValue(ctx, key{}, orgID) +} + +// From reads the organization id, reporting whether one was present. +func From(ctx context.Context) (string, bool) { + v, ok := ctx.Value(key{}).(string) + return v, ok && v != "" +} + +// MustFrom reads the organization id or returns ErrNoOrg. +func MustFrom(ctx context.Context) (string, error) { + if v, ok := From(ctx); ok { + return v, nil + } + return "", ErrNoOrg +} diff --git a/go-api/internal/repo/definitions.go b/go-api/internal/repo/definitions.go new file mode 100644 index 0000000..260befd --- /dev/null +++ b/go-api/internal/repo/definitions.go @@ -0,0 +1,553 @@ +package repo + +import ( + "context" + "fmt" + "strings" + + "github.com/krow/krow-backend/go-api/internal/authctx" + "github.com/krow/krow-backend/go-api/internal/domain" +) + +// DefinitionListParams holds the query parameters for listing authored definitions. +type DefinitionListParams struct { + Visibility string + Status string + DefinitionID string + Sort string + Desc bool + Limit int + Offset int +} + +// AgentInsertInput holds the fields to insert into agent_definitions. +type AgentInsertInput struct { + DefinitionID string + OrgID string + Visibility string + OwnerUserID *string + CreatedBy *string + Markdown string + Status string + Version int + Name string + Description string + Pages []string +} + +// AgentUpdateInput holds the fields to update in agent_definitions. +type AgentUpdateInput struct { + DefinitionID *string + Name *string + Description *string + Status *string + Version *int + Pages []string + Markdown *string + Visibility *string + OwnerUserID *string +} + +// SkillInsertInput holds the fields to insert into skill_definitions. +type SkillInsertInput struct { + DefinitionID string + OrgID string + Visibility string + OwnerUserID *string + CreatedBy *string + Markdown string + Status string + Name string + Description string + Pages []string +} + +// SkillUpdateInput holds the fields to update in skill_definitions. +type SkillUpdateInput struct { + DefinitionID *string + Name *string + Description *string + Status *string + Pages []string + Markdown *string + Visibility *string + OwnerUserID *string +} + +const agentDefinitionColumns = `id::text AS id, + definition_id, + org_id::text AS org_id, + visibility, + owner_user_id::text AS owner_user_id, + created_by::text AS created_by, + markdown, + status, + version, + name, + description, + pages, + to_char(created_date AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS created_date, + to_char(updated_date AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS updated_date` + +const skillDefinitionColumns = `id::text AS id, + definition_id, + org_id::text AS org_id, + visibility, + owner_user_id::text AS owner_user_id, + created_by::text AS created_by, + markdown, + status, + name, + description, + pages, + to_char(created_date AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS created_date, + to_char(updated_date AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS updated_date` + +// DefinitionsRepo handles persistence for agent_definitions and skill_definitions. +type DefinitionsRepo struct { + db Querier +} + +// NewDefinitionsRepo builds a repository over a pool or transaction. +func NewDefinitionsRepo(db Querier) *DefinitionsRepo { + return &DefinitionsRepo{db: db} +} + +/* ── Agents ─────────────────────────────────────────────────────────────── */ + +// ListAgents lists agent definitions matching the criteria with tenant isolation. +func (r *DefinitionsRepo) ListAgents(ctx context.Context, ident authctx.Identity, p DefinitionListParams) ([]domain.Record, int, error) { + b := &builder{} + b.where = append(b.where, fmt.Sprintf("org_id = %s::uuid", b.add(ident.OrgID))) + + switch p.Visibility { + case "personal": + b.where = append(b.where, fmt.Sprintf("(visibility = 'personal' AND owner_user_id = %s::uuid)", b.add(ident.UserID))) + case "organization": + b.where = append(b.where, "visibility = 'organization'") + default: + b.where = append(b.where, fmt.Sprintf("(visibility = 'organization' OR (visibility = 'personal' AND owner_user_id = %s::uuid))", b.add(ident.UserID))) + } + + if p.Status != "" { + b.where = append(b.where, fmt.Sprintf("status = %s::text", b.add(p.Status))) + } + if p.DefinitionID != "" { + b.where = append(b.where, fmt.Sprintf("definition_id = %s::text", b.add(p.DefinitionID))) + } + + countSQL := "SELECT count(*)::int FROM agent_definitions" + b.clause() + var total int + if err := r.db.QueryRow(ctx, countSQL, b.args...).Scan(&total); err != nil { + return nil, 0, translate(err) + } + + sortCol := "created_date" + switch p.Sort { + case "created_date", "updated_date", "name", "definition_id", "status", "version": + sortCol = p.Sort + } + dir := "ASC" + if p.Desc { + dir = "DESC" + } + orderClause := fmt.Sprintf(" ORDER BY %s %s, id %s", sortCol, dir, dir) + + limit := p.Limit + if limit <= 0 { + limit = 100 + } + offset := p.Offset + if offset < 0 { + offset = 0 + } + + limitClause := fmt.Sprintf(" LIMIT %s OFFSET %s", b.add(limit), b.add(offset)) + querySQL := "SELECT " + agentDefinitionColumns + " FROM agent_definitions" + b.clause() + orderClause + limitClause + + rows, err := r.db.Query(ctx, querySQL, b.args...) + if err != nil { + return nil, 0, translate(err) + } + defer rows.Close() + + records, err := collect(rows) + if err != nil { + return nil, 0, translate(err) + } + return records, total, nil +} + +// GetAgent retrieves one agent definition by id within the caller's tenant and ownership scope. +func (r *DefinitionsRepo) GetAgent(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) { + q := fmt.Sprintf( + `SELECT %s FROM agent_definitions + WHERE id = $1::uuid + AND org_id = $2::uuid + AND (visibility = 'organization' OR (visibility = 'personal' AND owner_user_id = $3::uuid)) + LIMIT 1`, + agentDefinitionColumns) + + rows, err := r.db.Query(ctx, q, id, ident.OrgID, ident.UserID) + if err != nil { + return nil, translate(err) + } + defer rows.Close() + + records, err := collect(rows) + if err != nil { + return nil, translate(err) + } + if len(records) == 0 { + return nil, nil + } + return records[0], nil +} + +// GetAgentByDefinitionID retrieves an agent definition by definition_id within the caller's scope, +// giving personal definitions precedence over organization definitions when shadowed. +func (r *DefinitionsRepo) GetAgentByDefinitionID(ctx context.Context, ident authctx.Identity, defID string) (domain.Record, error) { + q := fmt.Sprintf( + `SELECT %s FROM agent_definitions + WHERE definition_id = $1::text + AND org_id = $2::uuid + AND (visibility = 'organization' OR (visibility = 'personal' AND owner_user_id = $3::uuid)) + ORDER BY CASE WHEN visibility = 'personal' THEN 1 ELSE 2 END + LIMIT 1`, + agentDefinitionColumns) + + rows, err := r.db.Query(ctx, q, defID, ident.OrgID, ident.UserID) + if err != nil { + return nil, translate(err) + } + defer rows.Close() + + records, err := collect(rows) + if err != nil { + return nil, translate(err) + } + if len(records) == 0 { + return nil, nil + } + return records[0], nil +} + +// InsertAgent writes an agent definition and returns the stored row. +func (r *DefinitionsRepo) InsertAgent(ctx context.Context, ident authctx.Identity, in AgentInsertInput) (domain.Record, error) { + q := fmt.Sprintf( + `INSERT INTO agent_definitions ( + definition_id, org_id, visibility, owner_user_id, created_by, + markdown, status, version, name, description, pages + ) VALUES ( + $1::text, $2::uuid, $3::text, $4::uuid, $5::uuid, + $6::text, $7::text, $8::integer, $9::text, $10::text, $11::text[] + ) RETURNING %s`, agentDefinitionColumns) + + pages := in.Pages + if pages == nil { + pages = []string{} + } + + rows, err := r.db.Query(ctx, q, + in.DefinitionID, in.OrgID, in.Visibility, in.OwnerUserID, in.CreatedBy, + in.Markdown, in.Status, in.Version, in.Name, in.Description, pages) + if err != nil { + return nil, translate(err) + } + defer rows.Close() + + records, err := collect(rows) + if err != nil { + return nil, translate(err) + } + if len(records) == 0 { + return nil, fmt.Errorf("insert agent_definitions returned no row") + } + return records[0], nil +} + +// UpdateAgent updates fields on an agent definition and returns the full updated record. +func (r *DefinitionsRepo) UpdateAgent(ctx context.Context, ident authctx.Identity, id string, in AgentUpdateInput) (domain.Record, error) { + b := &builder{} + sets := []string{"updated_date = now()"} + + if in.DefinitionID != nil { + sets = append(sets, fmt.Sprintf("definition_id = %s::text", b.add(*in.DefinitionID))) + } + if in.Name != nil { + sets = append(sets, fmt.Sprintf("name = %s::text", b.add(*in.Name))) + } + if in.Description != nil { + sets = append(sets, fmt.Sprintf("description = %s::text", b.add(*in.Description))) + } + if in.Status != nil { + sets = append(sets, fmt.Sprintf("status = %s::text", b.add(*in.Status))) + } + if in.Version != nil { + sets = append(sets, fmt.Sprintf("version = %s::integer", b.add(*in.Version))) + } + if in.Pages != nil { + sets = append(sets, fmt.Sprintf("pages = %s::text[]", b.add(in.Pages))) + } + if in.Markdown != nil { + sets = append(sets, fmt.Sprintf("markdown = %s::text", b.add(*in.Markdown))) + } + if in.Visibility != nil { + sets = append(sets, fmt.Sprintf("visibility = %s::text", b.add(*in.Visibility))) + sets = append(sets, fmt.Sprintf("owner_user_id = %s::uuid", b.add(in.OwnerUserID))) + } + + b.where = append(b.where, fmt.Sprintf("id = %s::uuid", b.add(id))) + b.where = append(b.where, fmt.Sprintf("org_id = %s::uuid", b.add(ident.OrgID))) + b.where = append(b.where, fmt.Sprintf("(visibility = 'organization' OR (visibility = 'personal' AND owner_user_id = %s::uuid))", b.add(ident.UserID))) + + q := fmt.Sprintf("UPDATE agent_definitions SET %s%s RETURNING %s", + strings.Join(sets, ", "), b.clause(), agentDefinitionColumns) + + rows, err := r.db.Query(ctx, q, b.args...) + if err != nil { + return nil, translate(err) + } + defer rows.Close() + + records, err := collect(rows) + if err != nil { + return nil, translate(err) + } + if len(records) == 0 { + return nil, nil + } + return records[0], nil +} + +// DeleteAgent removes an agent definition matching id and access scope. +func (r *DefinitionsRepo) DeleteAgent(ctx context.Context, ident authctx.Identity, id string) (int64, error) { + q := `DELETE FROM agent_definitions + WHERE id = $1::uuid + AND org_id = $2::uuid + AND (visibility = 'organization' OR (visibility = 'personal' AND owner_user_id = $3::uuid))` + + tag, err := r.db.Exec(ctx, q, id, ident.OrgID, ident.UserID) + if err != nil { + return 0, translate(err) + } + return tag.RowsAffected(), nil +} + +/* ── Skills ─────────────────────────────────────────────────────────────── */ + +// ListSkills lists skill definitions matching the criteria with tenant isolation. +func (r *DefinitionsRepo) ListSkills(ctx context.Context, ident authctx.Identity, p DefinitionListParams) ([]domain.Record, int, error) { + b := &builder{} + b.where = append(b.where, fmt.Sprintf("org_id = %s::uuid", b.add(ident.OrgID))) + + switch p.Visibility { + case "personal": + b.where = append(b.where, fmt.Sprintf("(visibility = 'personal' AND owner_user_id = %s::uuid)", b.add(ident.UserID))) + case "organization": + b.where = append(b.where, "visibility = 'organization'") + default: + b.where = append(b.where, fmt.Sprintf("(visibility = 'organization' OR (visibility = 'personal' AND owner_user_id = %s::uuid))", b.add(ident.UserID))) + } + + if p.Status != "" { + b.where = append(b.where, fmt.Sprintf("status = %s::text", b.add(p.Status))) + } + if p.DefinitionID != "" { + b.where = append(b.where, fmt.Sprintf("definition_id = %s::text", b.add(p.DefinitionID))) + } + + countSQL := "SELECT count(*)::int FROM skill_definitions" + b.clause() + var total int + if err := r.db.QueryRow(ctx, countSQL, b.args...).Scan(&total); err != nil { + return nil, 0, translate(err) + } + + sortCol := "created_date" + switch p.Sort { + case "created_date", "updated_date", "name", "definition_id", "status": + sortCol = p.Sort + } + dir := "ASC" + if p.Desc { + dir = "DESC" + } + orderClause := fmt.Sprintf(" ORDER BY %s %s, id %s", sortCol, dir, dir) + + limit := p.Limit + if limit <= 0 { + limit = 100 + } + offset := p.Offset + if offset < 0 { + offset = 0 + } + + limitClause := fmt.Sprintf(" LIMIT %s OFFSET %s", b.add(limit), b.add(offset)) + querySQL := "SELECT " + skillDefinitionColumns + " FROM skill_definitions" + b.clause() + orderClause + limitClause + + rows, err := r.db.Query(ctx, querySQL, b.args...) + if err != nil { + return nil, 0, translate(err) + } + defer rows.Close() + + records, err := collect(rows) + if err != nil { + return nil, 0, translate(err) + } + return records, total, nil +} + +// GetSkill retrieves one skill definition by id within the caller's tenant and ownership scope. +func (r *DefinitionsRepo) GetSkill(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) { + q := fmt.Sprintf( + `SELECT %s FROM skill_definitions + WHERE id = $1::uuid + AND org_id = $2::uuid + AND (visibility = 'organization' OR (visibility = 'personal' AND owner_user_id = $3::uuid)) + LIMIT 1`, + skillDefinitionColumns) + + rows, err := r.db.Query(ctx, q, id, ident.OrgID, ident.UserID) + if err != nil { + return nil, translate(err) + } + defer rows.Close() + + records, err := collect(rows) + if err != nil { + return nil, translate(err) + } + if len(records) == 0 { + return nil, nil + } + return records[0], nil +} + +// GetSkillByDefinitionID retrieves a skill definition by definition_id within the caller's scope, +// giving personal definitions precedence over organization definitions when shadowed. +func (r *DefinitionsRepo) GetSkillByDefinitionID(ctx context.Context, ident authctx.Identity, defID string) (domain.Record, error) { + q := fmt.Sprintf( + `SELECT %s FROM skill_definitions + WHERE definition_id = $1::text + AND org_id = $2::uuid + AND (visibility = 'organization' OR (visibility = 'personal' AND owner_user_id = $3::uuid)) + ORDER BY CASE WHEN visibility = 'personal' THEN 1 ELSE 2 END + LIMIT 1`, + skillDefinitionColumns) + + rows, err := r.db.Query(ctx, q, defID, ident.OrgID, ident.UserID) + if err != nil { + return nil, translate(err) + } + defer rows.Close() + + records, err := collect(rows) + if err != nil { + return nil, translate(err) + } + if len(records) == 0 { + return nil, nil + } + return records[0], nil +} + +// InsertSkill writes a skill definition and returns the stored row. +func (r *DefinitionsRepo) InsertSkill(ctx context.Context, ident authctx.Identity, in SkillInsertInput) (domain.Record, error) { + q := fmt.Sprintf( + `INSERT INTO skill_definitions ( + definition_id, org_id, visibility, owner_user_id, created_by, + markdown, status, name, description, pages + ) VALUES ( + $1::text, $2::uuid, $3::text, $4::uuid, $5::uuid, + $6::text, $7::text, $8::text, $9::text, $10::text[] + ) RETURNING %s`, skillDefinitionColumns) + + pages := in.Pages + if pages == nil { + pages = []string{} + } + + rows, err := r.db.Query(ctx, q, + in.DefinitionID, in.OrgID, in.Visibility, in.OwnerUserID, in.CreatedBy, + in.Markdown, in.Status, in.Name, in.Description, pages) + if err != nil { + return nil, translate(err) + } + defer rows.Close() + + records, err := collect(rows) + if err != nil { + return nil, translate(err) + } + if len(records) == 0 { + return nil, fmt.Errorf("insert skill_definitions returned no row") + } + return records[0], nil +} + +// UpdateSkill updates fields on a skill definition and returns the full updated record. +func (r *DefinitionsRepo) UpdateSkill(ctx context.Context, ident authctx.Identity, id string, in SkillUpdateInput) (domain.Record, error) { + b := &builder{} + sets := []string{"updated_date = now()"} + + if in.DefinitionID != nil { + sets = append(sets, fmt.Sprintf("definition_id = %s::text", b.add(*in.DefinitionID))) + } + if in.Name != nil { + sets = append(sets, fmt.Sprintf("name = %s::text", b.add(*in.Name))) + } + if in.Description != nil { + sets = append(sets, fmt.Sprintf("description = %s::text", b.add(*in.Description))) + } + if in.Status != nil { + sets = append(sets, fmt.Sprintf("status = %s::text", b.add(*in.Status))) + } + if in.Pages != nil { + sets = append(sets, fmt.Sprintf("pages = %s::text[]", b.add(in.Pages))) + } + if in.Markdown != nil { + sets = append(sets, fmt.Sprintf("markdown = %s::text", b.add(*in.Markdown))) + } + if in.Visibility != nil { + sets = append(sets, fmt.Sprintf("visibility = %s::text", b.add(*in.Visibility))) + sets = append(sets, fmt.Sprintf("owner_user_id = %s::uuid", b.add(in.OwnerUserID))) + } + + b.where = append(b.where, fmt.Sprintf("id = %s::uuid", b.add(id))) + b.where = append(b.where, fmt.Sprintf("org_id = %s::uuid", b.add(ident.OrgID))) + b.where = append(b.where, fmt.Sprintf("(visibility = 'organization' OR (visibility = 'personal' AND owner_user_id = %s::uuid))", b.add(ident.UserID))) + + q := fmt.Sprintf("UPDATE skill_definitions SET %s%s RETURNING %s", + strings.Join(sets, ", "), b.clause(), skillDefinitionColumns) + + rows, err := r.db.Query(ctx, q, b.args...) + if err != nil { + return nil, translate(err) + } + defer rows.Close() + + records, err := collect(rows) + if err != nil { + return nil, translate(err) + } + if len(records) == 0 { + return nil, nil + } + return records[0], nil +} + +// DeleteSkill removes a skill definition matching id and access scope. +func (r *DefinitionsRepo) DeleteSkill(ctx context.Context, ident authctx.Identity, id string) (int64, error) { + q := `DELETE FROM skill_definitions + WHERE id = $1::uuid + AND org_id = $2::uuid + AND (visibility = 'organization' OR (visibility = 'personal' AND owner_user_id = $3::uuid))` + + tag, err := r.db.Exec(ctx, q, id, ident.OrgID, ident.UserID) + if err != nil { + return 0, translate(err) + } + return tag.RowsAffected(), nil +} diff --git a/go-api/internal/repo/repo.go b/go-api/internal/repo/repo.go new file mode 100644 index 0000000..f758649 --- /dev/null +++ b/go-api/internal/repo/repo.go @@ -0,0 +1,668 @@ +// Package repo is the PostgreSQL access layer. +// +// Every statement is built from a *domain.Resource: column lists are explicit +// and come from the generated descriptors, and every value reaches the database +// as a bind parameter cast to its declared type. No identifier is ever taken +// from user input — a filter or sort name is resolved to a *domain.Column +// first, and an unresolved name is rejected before any SQL is assembled. +package repo + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "github.com/krow/krow-backend/go-api/internal/authctx" + "github.com/krow/krow-backend/go-api/internal/domain" +) + +// Querier is satisfied by both *pgxpool.Pool and pgx.Tx, so every method here +// works inside or outside a transaction. +type Querier interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row + Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) +} + +// Repo reads and writes one resource. +type Repo struct { + res *domain.Resource + db Querier +} + +// New builds a repository for a resource over a pool or transaction. +func New(res *domain.Resource, db Querier) *Repo { return &Repo{res: res, db: db} } + +// Resource is the descriptor this repository serves. +func (r *Repo) Resource() *domain.Resource { return r.res } + +/* ── Projection ─────────────────────────────────────────────────────────── */ + +func (r *Repo) selectList() string { + parts := make([]string, 0, len(r.res.Columns)) + for _, c := range r.res.Columns { + parts = append(parts, c.SelectExpr()) + } + return strings.Join(parts, ", ") +} + +/* ── Predicates ─────────────────────────────────────────────────────────── */ + +type builder struct { + args []any + where []string +} + +func (b *builder) add(v any) string { + b.args = append(b.args, v) + return "$" + strconv.Itoa(len(b.args)) +} + +// scope applies organization scoping. A NULL org_id on an OrgNullable table +// means the shared platform library, which every organization can read. +func (b *builder) scope(res *domain.Resource, orgID string) { + p := b.add(orgID) + if res.OrgNullable { + b.where = append(b.where, fmt.Sprintf("(org_id = %s::uuid OR org_id IS NULL)", p)) + return + } + b.where = append(b.where, fmt.Sprintf("org_id = %s::uuid", p)) +} + +// ownership narrows the rows a caller may touch, beyond their organization. +// +// This is the second half of authorization and it lives HERE, in the WHERE +// clause, rather than in a loop over fetched rows. The difference is not +// stylistic: List runs `count(*)` over the same predicate, so a filtered-in-Go +// approach would return the right page and the wrong total, and would fetch +// rows the caller may not see in order to discard them. A row outside the +// predicate is never read. +// +// Only talent is scoped — Policy.ScopeFor decides that, not this function. +// Admin and employer see the whole organization, which is what an operator +// console is for. +// +// A role the API does not recognise matches nothing. That should be +// unreachable — the handler answers 403 before any query runs — but a +// scope-narrowing function whose failure mode is "see everything" is the wrong +// shape to leave lying around. +func (b *builder) ownership(res *domain.Resource, ident authctx.Identity) { + role, known := domain.ParseRole(ident.Role) + if !known { + b.where = append(b.where, "false") + return + } + + scope := res.Policy.ScopeFor(role) + switch scope.Kind { + case domain.ScopeNone: + return + + case domain.ScopeUserID: + col, ok := res.Column(scope.Column) + if !ok { + b.where = append(b.where, "false") + return + } + b.where = append(b.where, + fmt.Sprintf("%s = %s::%s", col.Name, b.add(ident.UserID), col.PGType)) + + case domain.ScopeEmail: + col, ok := res.Column(scope.Column) + if !ok { + b.where = append(b.where, "false") + return + } + // citext, so the comparison is case-insensitive — the same equality the + // rest of the domain uses for email. + b.where = append(b.where, + fmt.Sprintf("%s = %s::%s", col.Name, b.add(ident.Email), col.PGType)) + + case domain.ScopeActivePostings: + col, ok := res.Column(scope.Column) + if !ok { + b.where = append(b.where, "false") + return + } + b.where = append(b.where, + fmt.Sprintf("%s = %s::%s", col.Name, b.add("active"), col.PGType)) + + case domain.ScopeOwnApplications: + // Ownership by reference: the row names an application, the application + // names a person. The subquery is scoped to the organization as well as + // the email, so it cannot reach across tenants even if an id from + // another one were supplied. + apps, ok := domain.ResourceByPath["job-applications"] + if !ok { + b.where = append(b.where, "false") + return + } + col, colOK := res.Column(scope.Column) + if !colOK { + b.where = append(b.where, "false") + return + } + b.where = append(b.where, fmt.Sprintf( + "%s IN (SELECT id FROM %s WHERE org_id = %s::uuid AND email = %s::citext)", + col.Name, apps.Table, b.add(ident.OrgID), b.add(ident.Email))) + + default: + b.where = append(b.where, "false") + } +} + +// filters renders the contract's two operators and nothing else: equality for a +// single value, membership for several. See api-contract.md §6. +func (b *builder) filters(fs []domain.Filter) error { + for _, f := range fs { + if len(f.Values) == 1 { + v, err := bindValue(*f.Column, f.Values[0]) + if err != nil { + return err + } + b.where = append(b.where, + fmt.Sprintf("%s = %s::%s", f.Column.Name, b.add(v), f.Column.PGType)) + continue + } + vals := make([]string, 0, len(f.Values)) + vals = append(vals, f.Values...) + b.where = append(b.where, + fmt.Sprintf("%s = ANY(%s::%s[])", f.Column.Name, b.add(vals), arrayElem(f.Column))) + } + return nil +} + +func arrayElem(c *domain.Column) string { + if c.Kind == domain.KindEnum { + return c.PGType + } + switch c.PGType { + case "citext": + return "citext" + case "uuid": + return "uuid" + case "int", "bigint": + return c.PGType + default: + return "text" + } +} + +func (b *builder) clause() string { + if len(b.where) == 0 { + return "" + } + return " WHERE " + strings.Join(b.where, " AND ") +} + +/* ── Ordering ───────────────────────────────────────────────────────────── */ + +// orderBy renders the two ordering rules the contract calls out. +// +// NULLS LAST in *both* directions, because store.js's comparator returns before +// the descending negation is applied — PostgreSQL's default would put nulls +// first on DESC. And `, id` as a final tiebreaker, because JavaScript's sort is +// stable and PostgreSQL's is not. See api-contract.md §7.1 and §7.3. +// +// Names are qualified with the table so they bind to the real column rather +// than to a same-named output alias from the projection. +func (r *Repo) orderBy(p domain.ListParams) string { + if p.Sort == "" { + return fmt.Sprintf(" ORDER BY %s.id", r.res.Table) + } + dir := "ASC" + if p.Desc { + dir = "DESC" + } + return fmt.Sprintf(" ORDER BY %s.%s %s NULLS LAST, %s.id", + r.res.Table, p.Sort, dir, r.res.Table) +} + +/* ── Reads ──────────────────────────────────────────────────────────────── */ + +// List returns one page plus the total matching count. +func (r *Repo) List(ctx context.Context, ident authctx.Identity, p domain.ListParams) (*domain.Page, error) { + b := &builder{} + b.scope(r.res, ident.OrgID) + b.ownership(r.res, ident) + if err := b.filters(p.Filters); err != nil { + return nil, err + } + where := b.clause() + + var total int + countSQL := "SELECT count(*) FROM " + r.res.Table + where + if err := r.db.QueryRow(ctx, countSQL, b.args...).Scan(&total); err != nil { + return nil, fmt.Errorf("count %s: %w", r.res.Table, err) + } + + limitP := b.add(p.Limit) + offsetP := b.add(p.Offset) + q := "SELECT " + r.selectList() + " FROM " + r.res.Table + where + + r.orderBy(p) + " LIMIT " + limitP + " OFFSET " + offsetP + + rows, err := r.db.Query(ctx, q, b.args...) + if err != nil { + return nil, fmt.Errorf("list %s: %w", r.res.Table, err) + } + defer rows.Close() + + records, err := collect(rows) + if err != nil { + return nil, err + } + return &domain.Page{Records: records, Total: total, Limit: p.Limit, Offset: p.Offset}, nil +} + +// Get returns one record, or a nil record when nothing matches. +func (r *Repo) Get(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) { + b := &builder{} + b.scope(r.res, ident.OrgID) + b.ownership(r.res, ident) + b.where = append(b.where, "id = "+b.add(id)+"::uuid") + + q := "SELECT " + r.selectList() + " FROM " + r.res.Table + b.clause() + " LIMIT 1" + rows, err := r.db.Query(ctx, q, b.args...) + if err != nil { + return nil, fmt.Errorf("get %s: %w", r.res.Table, err) + } + defer rows.Close() + + records, err := collect(rows) + if err != nil || len(records) == 0 { + return nil, err + } + return records[0], nil +} + +/* ── Writes ─────────────────────────────────────────────────────────────── */ + +// derivedValues are the columns this caller does not get to choose. +// +// Every column here is also ReadOnly in the descriptors, so a value in the +// request body has already been dropped by service.validate. This supplies what +// goes in instead: a fact about the authenticated session. +// +// Two shapes share this mechanism and it is worth keeping them apart. `created_by` +// and the user_activity identity columns record WHO ACTED — always the session +// user, whatever their role. The TalentOnly ones record WHO THE ROW IS ABOUT, +// and only a talent caller is necessarily writing about themselves; when an +// admin creates a candidate's worker profile, the subject is the candidate, so +// nothing is derived and the column is left to the database's default (NULL). +func (r *Repo) derivedValues(ident authctx.Identity) map[string]any { + if r.res.Policy == nil || len(r.res.Policy.Derived) == 0 { + return nil + } + isTalent := ident.Role == string(domain.RoleTalent) + + out := make(map[string]any, len(r.res.Policy.Derived)) + for _, d := range r.res.Policy.Derived { + if d.TalentOnly && !isTalent { + continue + } + switch d.Source { + case domain.DeriveUserID: + out[d.Column] = ident.UserID + case domain.DeriveEmail: + out[d.Column] = ident.Email + case domain.DeriveFullName: + out[d.Column] = ident.FullName + case domain.DeriveAccountType: + out[d.Column] = ident.AccountType + } + } + return out +} + +// guardInsert refuses a create whose ownership is expressed by reference rather +// than by a column of its own. +// +// Only ai_interviews needs this: the row names an application, and a talent +// caller may only interview for an application of theirs. There is no column on +// the interview to derive, so the reference itself has to be checked. +// +// The refusal is the same 404 an application that does not exist would produce. +// Answering "that application is not yours" would confirm it exists. +func (r *Repo) guardInsert(ctx context.Context, ident authctx.Identity, in domain.Record) error { + role, known := domain.ParseRole(ident.Role) + if !known { + return domain.Forbidden() + } + scope := r.res.Policy.ScopeFor(role) + if scope.Kind != domain.ScopeOwnApplications { + return nil + } + + apps, ok := domain.ResourceByPath["job-applications"] + if !ok { + return domain.Internal(errors.New("repo: job-applications resource is missing")) + } + ref, _ := in[scope.Column].(string) + if ref == "" { + // A required reference that is absent is a validation problem, and the + // service's Required check has already reported it. + return nil + } + + var owns bool + q := fmt.Sprintf( + `SELECT EXISTS (SELECT 1 FROM %s WHERE org_id = $1::uuid AND id = $2::uuid AND email = $3::citext)`, + apps.Table) + if err := r.db.QueryRow(ctx, q, ident.OrgID, ref, ident.Email).Scan(&owns); err != nil { + return fmt.Errorf("check application ownership: %w", err) + } + if !owns { + return domain.NotFound(apps.Name, ref) + } + return nil +} + +// Insert writes a record and returns it as the API represents it. +func (r *Repo) Insert(ctx context.Context, ident authctx.Identity, in domain.Record) (domain.Record, error) { + if err := r.guardInsert(ctx, ident, in); err != nil { + return nil, err + } + + cols := []string{"org_id"} + b := &builder{} + // The organization is the session's, never the body's. org_id is ReadOnly + // on every resource, so this is the only way a value reaches the column. + vals := []string{b.add(ident.OrgID) + "::uuid"} + + derived := r.derivedValues(ident) + for _, c := range r.res.Columns { + // A derived column is written whether or not it is ReadOnly, and it + // overrides anything the caller sent — which is what closes the + // attribution holes: a talent caller cannot file an application, a + // profile or an audit entry under somebody else's name. + if v, ok := derived[c.Name]; ok { + cols = append(cols, c.Name) + vals = append(vals, b.add(v)+"::"+c.PGType) + continue + } + if c.ReadOnly { + continue + } + v, present := in[c.Name] + if !present { + continue // let the column default apply + } + bound, err := bindValue(c, v) + if err != nil { + return nil, err + } + cols = append(cols, c.Name) + vals = append(vals, b.add(bound)+"::"+c.PGType) + } + + q := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s) RETURNING %s", + r.res.Table, strings.Join(cols, ", "), strings.Join(vals, ", "), r.selectList()) + + rows, err := r.db.Query(ctx, q, b.args...) + if err != nil { + return nil, translate(err) + } + defer rows.Close() + // pgx does not execute until the rows are read, so a constraint violation + // arrives here rather than from Query above. Both paths must translate. + records, err := collect(rows) + if err != nil { + return nil, translate(err) + } + if len(records) == 0 { + return nil, fmt.Errorf("insert %s returned no row", r.res.Table) + } + return records[0], nil +} + +// Update applies a shallow merge: only the supplied columns are written. +// +// A key that is absent is left alone; a key present with null sets NULL. There +// is no deep merge — store.js spreads one level, and useSubmitChallenge relies +// on whole arrays being replaced rather than appended to. See api-contract.md §3.2. +func (r *Repo) Update(ctx context.Context, ident authctx.Identity, id string, patch domain.Record) (domain.Record, error) { + b := &builder{} + sets := []string{} + + for _, c := range r.res.Columns { + if c.ReadOnly { + continue + } + v, present := patch[c.Name] + if !present { + continue + } + bound, err := bindValue(c, v) + if err != nil { + return nil, err + } + sets = append(sets, fmt.Sprintf("%s = %s::%s", c.Name, b.add(bound), c.PGType)) + } + + // updated_date is always server-owned. user_activity is append-only and has + // no such column, so this is conditional rather than assumed. + if _, ok := r.res.Column("updated_date"); ok { + sets = append(sets, "updated_date = now()") + } + if len(sets) == 0 { + return r.Get(ctx, ident, id) + } + + b.scope(r.res, ident.OrgID) + b.ownership(r.res, ident) + b.where = append(b.where, "id = "+b.add(id)+"::uuid") + + q := fmt.Sprintf("UPDATE %s SET %s%s RETURNING %s", + r.res.Table, strings.Join(sets, ", "), b.clause(), r.selectList()) + + rows, err := r.db.Query(ctx, q, b.args...) + if err != nil { + return nil, translate(err) + } + defer rows.Close() + records, err := collect(rows) + if err != nil { + return nil, translate(err) + } + if len(records) == 0 { + return nil, nil + } + return records[0], nil +} + +// Delete removes a record and reports how many rows went. +// +// A miss is not an error: store.js filters its array and returns { id } +// whether or not anything matched. See api-contract.md §12.7. +func (r *Repo) Delete(ctx context.Context, ident authctx.Identity, id string) (int64, error) { + b := &builder{} + b.scope(r.res, ident.OrgID) + b.ownership(r.res, ident) + b.where = append(b.where, "id = "+b.add(id)+"::uuid") + + tag, err := r.db.Exec(ctx, "DELETE FROM "+r.res.Table+b.clause(), b.args...) + if err != nil { + return 0, translate(err) + } + return tag.RowsAffected(), nil +} + +/* ── Scanning ───────────────────────────────────────────────────────────── */ + +func collect(rows pgx.Rows) ([]domain.Record, error) { + fields := rows.FieldDescriptions() + out := make([]domain.Record, 0, 16) + for rows.Next() { + vals, err := rows.Values() + if err != nil { + return nil, err + } + rec := make(domain.Record, len(fields)) + for i, f := range fields { + rec[string(f.Name)] = normalise(vals[i]) + } + out = append(out, rec) + } + return out, rows.Err() +} + +// normalise flattens the few pgx representations that would not JSON-encode the +// way the frontend expects. Most values arrive ready to use because the +// projection casts them (see Column.SelectExpr). +func normalise(v any) any { + switch t := v.(type) { + case nil: + return nil + case [16]byte: // a uuid that slipped through without a ::text cast + return fmt.Sprintf("%x-%x-%x-%x-%x", t[0:4], t[4:6], t[6:8], t[8:10], t[10:16]) + case []any: + out := make([]any, len(t)) + for i, e := range t { + out[i] = normalise(e) + } + return out + default: + return v + } +} + +/* ── Binding ────────────────────────────────────────────────────────────── */ + +// bindValue converts a decoded-JSON value into something pgx can send for a +// column of this type. Everything is explicitly cast in the SQL, so the job +// here is only to pick a Go representation PostgreSQL will accept. +func bindValue(c domain.Column, v any) (any, error) { + if v == nil { + return nil, nil + } + switch c.Kind { + case domain.KindTextArray: + switch t := v.(type) { + case []any: + out := make([]string, 0, len(t)) + for _, e := range t { + s, ok := e.(string) + if !ok { + return nil, domain.Validation( + fmt.Sprintf("%s must be an array of strings", c.Name), + map[string]string{c.Name: "expected string elements"}) + } + out = append(out, s) + } + return out, nil + case []string: + return t, nil + default: + return nil, domain.Validation( + fmt.Sprintf("%s must be an array", c.Name), + map[string]string{c.Name: "expected an array"}) + } + + case domain.KindJSON: + raw, err := json.Marshal(v) + if err != nil { + return nil, domain.Validation(fmt.Sprintf("%s is not encodable as JSON", c.Name), nil) + } + return raw, nil + + case domain.KindInt: + switch t := v.(type) { + case float64: + if t != float64(int64(t)) { + return nil, domain.Validation( + fmt.Sprintf("%s must be a whole number", c.Name), + map[string]string{c.Name: "expected an integer"}) + } + return int64(t), nil + case string: + n, err := strconv.ParseInt(t, 10, 64) + if err != nil { + return nil, domain.Validation( + fmt.Sprintf("%s must be a whole number", c.Name), + map[string]string{c.Name: "expected an integer"}) + } + return n, nil + case int64: + return t, nil + case int: + return int64(t), nil + } + return nil, domain.Validation(fmt.Sprintf("%s must be a number", c.Name), nil) + + case domain.KindFloat: + switch t := v.(type) { + case float64: + return t, nil + case string: + f, err := strconv.ParseFloat(t, 64) + if err != nil { + return nil, domain.Validation(fmt.Sprintf("%s must be a number", c.Name), nil) + } + return f, nil + } + return nil, domain.Validation(fmt.Sprintf("%s must be a number", c.Name), nil) + + case domain.KindBool: + switch t := v.(type) { + case bool: + return t, nil + case string: + b, err := strconv.ParseBool(t) + if err != nil { + return nil, domain.Validation(fmt.Sprintf("%s must be a boolean", c.Name), nil) + } + return b, nil + } + return nil, domain.Validation(fmt.Sprintf("%s must be a boolean", c.Name), nil) + + default: // strings, enums, uuids, dates, timestamps + s, ok := v.(string) + if !ok { + return nil, domain.Validation( + fmt.Sprintf("%s must be a string", c.Name), + map[string]string{c.Name: "expected a string"}) + } + return s, nil + } +} + +/* ── Error translation ──────────────────────────────────────────────────── */ + +// translate maps PostgreSQL's SQLSTATE codes onto the contract's error codes, +// so a constraint the database enforces surfaces as the documented API error +// rather than as a 500. +func translate(err error) error { + var pg *pgconn.PgError + if !errors.As(err, &pg) { + return err + } + switch pg.Code { + case "23505": // unique_violation + return domain.Conflict(pg.Detail) + case "23503": // foreign_key_violation + return domain.Validation("referenced record does not exist", + map[string]string{constraintField(pg): "no such record"}) + case "23514": // check_violation + return domain.Validation("value violates constraint "+pg.ConstraintName, + map[string]string{constraintField(pg): "constraint " + pg.ConstraintName}) + case "23502": // not_null_violation + return domain.Validation(pg.ColumnName+" must not be null", + map[string]string{pg.ColumnName: "required"}) + case "22P02", "22007", "22008": // invalid text representation / datetime + return domain.Validation("value is not valid for its column type", nil) + } + return err +} + +func constraintField(pg *pgconn.PgError) string { + if pg.ColumnName != "" { + return pg.ColumnName + } + return pg.ConstraintName +} diff --git a/go-api/internal/runtime/executor.go b/go-api/internal/runtime/executor.go new file mode 100644 index 0000000..7cdf79d --- /dev/null +++ b/go-api/internal/runtime/executor.go @@ -0,0 +1,129 @@ +package runtime + +import ( + "context" + + "github.com/krow/krow-backend/go-api/internal/authctx" + "github.com/krow/krow-backend/go-api/internal/repo" +) + +// AgentExecutor is the boundary interface for executing an authored agent. +type AgentExecutor interface { + ExecuteAgent(ctx context.Context, agent *Agent, input ExecutionInput) (*ExecutionResult, error) +} + +// SkillExecutor is the boundary interface for executing an authored skill. +type SkillExecutor interface { + ExecuteSkill(ctx context.Context, skill *Skill, input ExecutionInput) (*ExecutionResult, error) +} + +// UnavailableExecutor is the Phase 4F default stub executor that explicitly refuses execution +// until real AI / Owliver / LangGraph executors are installed in Phase 5. +type UnavailableExecutor struct{} + +// ExecuteAgent implements AgentExecutor by returning ErrExecutorUnavailable. +func (u *UnavailableExecutor) ExecuteAgent(_ context.Context, agent *Agent, _ ExecutionInput) (*ExecutionResult, error) { + skillIDs := make([]string, len(agent.ResolvedSkills)) + for i, s := range agent.ResolvedSkills { + skillIDs[i] = s.ID + } + + return &ExecutionResult{ + Success: false, + AgentID: agent.ID, + AgentVersion: agent.Version, + ResolvedSkills: skillIDs, + Error: ErrExecutorUnavailable, + }, ErrExecutorUnavailable +} + +// ExecuteSkill implements SkillExecutor by returning ErrExecutorUnavailable. +func (u *UnavailableExecutor) ExecuteSkill(_ context.Context, skill *Skill, _ ExecutionInput) (*ExecutionResult, error) { + return &ExecutionResult{ + Success: false, + Error: ErrExecutorUnavailable, + }, ErrExecutorUnavailable +} + +// Engine coordinates runtime loading, eligibility checks, dependency resolution and execution. +type Engine struct { + Loader *Loader + AgentExec AgentExecutor + SkillExec SkillExecutor +} + +// Option configures the runtime engine. +type Option func(*Engine) + +// WithAgentExecutor overrides the agent executor implementation. +func WithAgentExecutor(exec AgentExecutor) Option { + return func(e *Engine) { + if exec != nil { + e.AgentExec = exec + } + } +} + +// WithSkillExecutor overrides the skill executor implementation. +func WithSkillExecutor(exec SkillExecutor) Option { + return func(e *Engine) { + if exec != nil { + e.SkillExec = exec + } + } +} + +// NewEngine builds a runtime engine over a database querier. +func NewEngine(db repo.Querier, opts ...Option) *Engine { + e := &Engine{ + Loader: NewLoader(db), + AgentExec: &UnavailableExecutor{}, + SkillExec: &UnavailableExecutor{}, + } + for _, opt := range opts { + opt(e) + } + return e +} + +// RunAgent loads an executable agent with dependencies and dispatches to the executor boundary. +func (e *Engine) RunAgent(ctx context.Context, ident authctx.Identity, idOrDefID string, input ExecutionInput) (*ExecutionResult, error) { + agent, err := e.Loader.LoadExecutableAgent(ctx, ident, idOrDefID) + if err != nil { + return &ExecutionResult{ + Success: false, + Error: err, + }, err + } + + res, err := e.AgentExec.ExecuteAgent(ctx, agent, input) + if res == nil { + res = &ExecutionResult{ + Success: err == nil, + AgentID: agent.ID, + AgentVersion: agent.Version, + Error: err, + } + } + return res, err +} + +// RunSkill loads an executable skill and dispatches to the executor boundary. +func (e *Engine) RunSkill(ctx context.Context, ident authctx.Identity, idOrDefID string, input ExecutionInput) (*ExecutionResult, error) { + skill, err := e.Loader.LoadExecutableSkill(ctx, ident, idOrDefID) + if err != nil { + return &ExecutionResult{ + Success: false, + Error: err, + }, err + } + + res, err := e.SkillExec.ExecuteSkill(ctx, skill, input) + if res == nil { + res = &ExecutionResult{ + Success: err == nil, + Error: err, + } + } + return res, err +} diff --git a/go-api/internal/runtime/loader.go b/go-api/internal/runtime/loader.go new file mode 100644 index 0000000..f7e2401 --- /dev/null +++ b/go-api/internal/runtime/loader.go @@ -0,0 +1,237 @@ +package runtime + +import ( + "context" + "errors" + "fmt" + "regexp" + + "github.com/krow/krow-backend/go-api/internal/authctx" + "github.com/krow/krow-backend/go-api/internal/definition" + "github.com/krow/krow-backend/go-api/internal/domain" + "github.com/krow/krow-backend/go-api/internal/repo" +) + +var uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + +func isUUID(s string) bool { + return uuidPattern.MatchString(s) +} + +// Loader loads and validates authored definitions into runtime representations with tenant isolation. +type Loader struct { + repo *repo.DefinitionsRepo +} + +// NewLoader builds a runtime definition loader over a storage repository. +func NewLoader(db repo.Querier) *Loader { + return &Loader{repo: repo.NewDefinitionsRepo(db)} +} + +// LoadAgent loads an agent definition by id or definition_id, parsing it into a runtime representation. +func (l *Loader) LoadAgent(ctx context.Context, ident authctx.Identity, idOrDefID string) (*Agent, error) { + var ( + rec domain.Record + err error + ) + + if isUUID(idOrDefID) { + rec, err = l.repo.GetAgent(ctx, ident, idOrDefID) + } else { + rec, err = l.repo.GetAgentByDefinitionID(ctx, ident, idOrDefID) + } + + if err != nil { + return nil, err + } + if rec == nil { + return nil, fmt.Errorf("%w: agent %q", ErrNotFound, idOrDefID) + } + + rawMD, ok := rec["markdown"].(string) + if !ok || rawMD == "" { + return nil, fmt.Errorf("%w: missing markdown payload for agent %q", ErrInvalidDefinition, idOrDefID) + } + + if err := definition.ValidateAgent(rawMD); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidDefinition, err) + } + + parsed, err := definition.ParseAgent(rawMD, definition.Options{}) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidDefinition, err) + } + + agent := &Agent{ + ID: parsed.ID, + DatabaseID: rec["id"].(string), + Name: parsed.Name, + Description: parsed.Description, + Status: parsed.Status, + Version: parsed.Version, + Visibility: rec["visibility"].(string), + Pages: parsed.Pages, + Icon: parsed.Icon, + Reasoning: parsed.Reasoning, + Trigger: parsed.Trigger, + WebSearch: parsed.WebSearch, + Instructions: parsed.Instructions, + Skills: parsed.Skills, + Subagents: parsed.Subagents, + RawMarkdown: rawMD, + } + + if rec["owner_user_id"] != nil { + if uid, ok := rec["owner_user_id"].(string); ok && uid != "" { + agent.OwnerUserID = &uid + } + } + + return agent, nil +} + +// LoadSkill loads a skill definition by id or definition_id, parsing it into a runtime representation. +func (l *Loader) LoadSkill(ctx context.Context, ident authctx.Identity, idOrDefID string) (*Skill, error) { + var ( + rec domain.Record + err error + ) + + if isUUID(idOrDefID) { + rec, err = l.repo.GetSkill(ctx, ident, idOrDefID) + } else { + rec, err = l.repo.GetSkillByDefinitionID(ctx, ident, idOrDefID) + } + + if err != nil { + return nil, err + } + if rec == nil { + return nil, fmt.Errorf("%w: skill %q", ErrNotFound, idOrDefID) + } + + rawMD, ok := rec["markdown"].(string) + if !ok || rawMD == "" { + return nil, fmt.Errorf("%w: missing markdown payload for skill %q", ErrInvalidDefinition, idOrDefID) + } + + if err := definition.ValidateSkill(rawMD); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidDefinition, err) + } + + parsed, err := definition.ParseSkill(rawMD, definition.Options{}) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidDefinition, err) + } + + skill := &Skill{ + ID: parsed.ID, + DatabaseID: rec["id"].(string), + Name: parsed.Name, + Description: parsed.Description, + Status: parsed.Status, + Visibility: rec["visibility"].(string), + Pages: parsed.Pages, + Kind: parsed.Kind, + Category: parsed.Category, + Actions: parsed.Actions, + Triggers: parsed.Triggers, + Prompt: parsed.Prompt, + SkillID: parsed.SkillID, + Body: parsed.Body, + RawMarkdown: rawMD, + } + + if rec["owner_user_id"] != nil { + if uid, ok := rec["owner_user_id"].(string); ok && uid != "" { + skill.OwnerUserID = &uid + } + } + + return skill, nil +} + +// ResolveAgentDependencies resolves all skill dependencies referenced by the agent within caller scope. +func (l *Loader) ResolveAgentDependencies(ctx context.Context, ident authctx.Identity, agent *Agent) error { + if len(agent.Skills) == 0 { + agent.ResolvedSkills = []*Skill{} + return nil + } + + visited := make(map[string]*Skill) + inProgress := make(map[string]bool) + resolved := make([]*Skill, 0, len(agent.Skills)) + + for _, skillID := range agent.Skills { + if _, ok := visited[skillID]; ok { + // Deterministic deduplication + continue + } + if inProgress[skillID] { + return fmt.Errorf("%w: skill %q", ErrCircularDependency, skillID) + } + inProgress[skillID] = true + + skill, err := l.LoadSkill(ctx, ident, skillID) + if err != nil { + if errors.Is(err, ErrNotFound) { + return fmt.Errorf("%w: skill %q", ErrDependencyMissing, skillID) + } + return err + } + if skill.Status != "active" { + return fmt.Errorf("%w: skill %q has status %q", ErrDependencyInactive, skillID, skill.Status) + } + + inProgress[skillID] = false + visited[skillID] = skill + resolved = append(resolved, skill) + } + + agent.ResolvedSkills = resolved + return nil +} + +// LoadExecutableAgent loads an agent, verifies its published status, and resolves all active dependencies. +func (l *Loader) LoadExecutableAgent(ctx context.Context, ident authctx.Identity, idOrDefID string) (*Agent, error) { + agent, err := l.LoadAgent(ctx, ident, idOrDefID) + if err != nil { + return nil, err + } + + switch agent.Status { + case "published": + // Eligible + case "draft": + return nil, fmt.Errorf("%w: agent %q is in draft status", ErrDraftAgent, agent.ID) + case "archived": + return nil, fmt.Errorf("%w: agent %q is archived", ErrArchivedAgent, agent.ID) + default: + return nil, fmt.Errorf("%w: agent %q has unsupported status %q", ErrNotExecutable, agent.ID, agent.Status) + } + + if err := l.ResolveAgentDependencies(ctx, ident, agent); err != nil { + return nil, err + } + + return agent, nil +} + +// LoadExecutableSkill loads a skill and verifies its active status. +func (l *Loader) LoadExecutableSkill(ctx context.Context, ident authctx.Identity, idOrDefID string) (*Skill, error) { + skill, err := l.LoadSkill(ctx, ident, idOrDefID) + if err != nil { + return nil, err + } + + switch skill.Status { + case "active": + // Eligible + case "inactive": + return nil, fmt.Errorf("%w: skill %q is inactive", ErrInactiveSkill, skill.ID) + default: + return nil, fmt.Errorf("%w: skill %q has unsupported status %q", ErrNotExecutable, skill.ID, skill.Status) + } + + return skill, nil +} diff --git a/go-api/internal/runtime/runtime_test.go b/go-api/internal/runtime/runtime_test.go new file mode 100644 index 0000000..f838f67 --- /dev/null +++ b/go-api/internal/runtime/runtime_test.go @@ -0,0 +1,890 @@ +package runtime_test + +import ( + "context" + "errors" + "fmt" + "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/runtime" + "github.com/krow/krow-backend/go-api/internal/testutil" +) + +type fixture struct { + h *testutil.Harness + loader *runtime.Loader + engine *runtime.Engine + defRepo *repo.DefinitionsRepo + org1 string + org2 string + userA authctx.Identity + userB authctx.Identity + userOther authctx.Identity +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + h := testutil.New(t) + ctx := context.Background() + + var org2 string + if err := h.Pool.QueryRow(ctx, + `INSERT INTO organizations (name, slug) VALUES ('Second Org', 'second-org') RETURNING id::text`). + Scan(&org2); err != nil { + t.Fatalf("create second org: %v", err) + } + + var userAID, userBID, userOtherID string + if err := h.Pool.QueryRow(ctx, + `INSERT INTO users (org_id, full_name, email, role, status) VALUES ($1::uuid, 'User A', 'user-a@example.test', 'admin', 'active') RETURNING id::text`, + h.OrgID).Scan(&userAID); err != nil { + t.Fatalf("create userA: %v", err) + } + if err := h.Pool.QueryRow(ctx, + `INSERT INTO users (org_id, full_name, email, role, status) VALUES ($1::uuid, 'User B', 'user-b@example.test', 'talent', 'active') RETURNING id::text`, + h.OrgID).Scan(&userBID); err != nil { + t.Fatalf("create userB: %v", err) + } + if err := h.Pool.QueryRow(ctx, + `INSERT INTO users (org_id, full_name, email, role, status) VALUES ($1::uuid, 'User Other', 'user-other@example.test', 'admin', 'active') RETURNING id::text`, + org2).Scan(&userOtherID); err != nil { + t.Fatalf("create userOther: %v", err) + } + + f := &fixture{ + h: h, + loader: runtime.NewLoader(h.Pool), + engine: runtime.NewEngine(h.Pool), + defRepo: repo.NewDefinitionsRepo(h.Pool), + org1: h.OrgID, + org2: org2, + userA: authctx.Identity{ + UserID: userAID, + OrgID: h.OrgID, + Role: "admin", + Email: "user-a@example.test", + }, + userB: authctx.Identity{ + UserID: userBID, + OrgID: h.OrgID, + Role: "talent", + Email: "user-b@example.test", + }, + userOther: authctx.Identity{ + UserID: userOtherID, + OrgID: org2, + Role: "admin", + Email: "user-other@example.test", + }, + } + return f +} + +/* ── 1. Loader Tests ──────────────────────────────────────────────────────── */ + +func TestRuntimeLoader_Agent(t *testing.T) { + f := newFixture(t) + ctx := context.Background() + + // 1. Published org agent + agentMD := `--- +id: talent-scout +name: Talent Scout +description: Discovers matching candidates +status: published +version: 2 +pages: + - candidates +icon: sparkles +reasoning: deep +trigger: manual +webSearch: true +skills: + - resume-evaluator +subagents: + - profile-enricher +--- + +## Instructions +Review candidate profiles with diligence. +` + rec, err := f.defRepo.InsertAgent(ctx, f.userA, repo.AgentInsertInput{ + DefinitionID: "talent-scout", + OrgID: f.org1, + Visibility: "organization", + CreatedBy: &f.userA.UserID, + Markdown: agentMD, + Status: "published", + Version: 2, + Name: "Talent Scout", + Description: "Discovers matching candidates", + Pages: []string{"candidates"}, + }) + if err != nil { + t.Fatalf("insert agent: %v", err) + } + dbUUID := rec["id"].(string) + + // Load by definition_id + agentByDefID, err := f.loader.LoadAgent(ctx, f.userB, "talent-scout") + if err != nil { + t.Fatalf("load agent by definition_id: %v", err) + } + if agentByDefID.ID != "talent-scout" || agentByDefID.DatabaseID != dbUUID { + t.Errorf("agent ID mismatch: %+v", agentByDefID) + } + if agentByDefID.Name != "Talent Scout" || agentByDefID.Version != 2 || agentByDefID.Status != "published" { + t.Errorf("agent fields mismatch: %+v", agentByDefID) + } + if agentByDefID.Icon != "sparkles" || agentByDefID.Reasoning != "deep" || !agentByDefID.WebSearch { + t.Errorf("agent config mismatch: %+v", agentByDefID) + } + if len(agentByDefID.Skills) != 1 || agentByDefID.Skills[0] != "resume-evaluator" { + t.Errorf("agent skills mismatch: %v", agentByDefID.Skills) + } + if agentByDefID.Instructions == "" || agentByDefID.RawMarkdown != agentMD { + t.Errorf("agent markdown/instructions mismatch") + } + + // Load by UUID + agentByUUID, err := f.loader.LoadAgent(ctx, f.userA, dbUUID) + if err != nil { + t.Fatalf("load agent by UUID: %v", err) + } + if agentByUUID.ID != "talent-scout" { + t.Errorf("agent by UUID ID mismatch: %+v", agentByUUID) + } + + // Personal agent for User B + persMD := `--- +id: personal-agent +name: Personal Agent +status: draft +version: 1 +pages: + - candidates +--- +## Instructions +Personal instructions. +` + persRec, err := f.defRepo.InsertAgent(ctx, f.userB, repo.AgentInsertInput{ + DefinitionID: "personal-agent", + OrgID: f.org1, + Visibility: "personal", + OwnerUserID: &f.userB.UserID, + CreatedBy: &f.userB.UserID, + Markdown: persMD, + Status: "draft", + Version: 1, + Name: "Personal Agent", + Pages: []string{"candidates"}, + }) + if err != nil { + t.Fatalf("insert personal agent: %v", err) + } + persUUID := persRec["id"].(string) + + // Owner (User B) can load personal agent + persAgent, err := f.loader.LoadAgent(ctx, f.userB, "personal-agent") + if err != nil { + t.Fatalf("load own personal agent: %v", err) + } + if persAgent.DatabaseID != persUUID { + t.Errorf("personal agent uuid mismatch: %s != %s", persAgent.DatabaseID, persUUID) + } + + // Other user in same org (User A) CANNOT load User B's personal agent -> ErrNotFound + _, err = f.loader.LoadAgent(ctx, f.userA, "personal-agent") + if !errors.Is(err, runtime.ErrNotFound) { + t.Errorf("userA loading userB personal agent: got error %v, want ErrNotFound", err) + } + + // Outsider CANNOT load org agent from org 1 -> ErrNotFound + _, err = f.loader.LoadAgent(ctx, f.userOther, "talent-scout") + if !errors.Is(err, runtime.ErrNotFound) { + t.Errorf("outsider loading org1 agent: got error %v, want ErrNotFound", err) + } + + // Missing agent -> ErrNotFound + _, err = f.loader.LoadAgent(ctx, f.userA, "nonexistent-agent") + if !errors.Is(err, runtime.ErrNotFound) { + t.Errorf("load nonexistent agent: got %v, want ErrNotFound", err) + } +} + +func TestRuntimeLoader_Skill(t *testing.T) { + f := newFixture(t) + ctx := context.Background() + + skillMD := `--- +id: resume-evaluator +name: Resume Evaluator +description: Evaluates candidate resume text +status: active +pages: + - candidates +category: screening +actions: + - score + - summarize +triggers: + - resume + - cv +prompt: Evaluate the candidate resume thoroughly. +--- + +# Resume Evaluator Body +Detailed skill instructions. +` + rec, err := f.defRepo.InsertSkill(ctx, f.userA, repo.SkillInsertInput{ + DefinitionID: "resume-evaluator", + OrgID: f.org1, + Visibility: "organization", + CreatedBy: &f.userA.UserID, + Markdown: skillMD, + Status: "active", + Name: "Resume Evaluator", + Description: "Evaluates candidate resume text", + Pages: []string{"candidates"}, + }) + if err != nil { + t.Fatalf("insert skill: %v", err) + } + skillUUID := rec["id"].(string) + + // Load by definition_id + skillByDefID, err := f.loader.LoadSkill(ctx, f.userB, "resume-evaluator") + if err != nil { + t.Fatalf("load skill by definition_id: %v", err) + } + if skillByDefID.ID != "resume-evaluator" || skillByDefID.DatabaseID != skillUUID { + t.Errorf("skill ID mismatch: %+v", skillByDefID) + } + if skillByDefID.Name != "Resume Evaluator" || skillByDefID.Status != "active" { + t.Errorf("skill fields mismatch: %+v", skillByDefID) + } + if skillByDefID.Category != "screening" || len(skillByDefID.Actions) != 2 || len(skillByDefID.Triggers) != 2 { + t.Errorf("skill actions/triggers mismatch: %+v", skillByDefID) + } + if skillByDefID.Prompt == nil || *skillByDefID.Prompt != "Evaluate the candidate resume thoroughly." { + t.Errorf("skill prompt mismatch: %v", skillByDefID.Prompt) + } + if skillByDefID.RawMarkdown != skillMD { + t.Errorf("skill raw markdown not verbatim") + } + + // Load by UUID + skillByUUID, err := f.loader.LoadSkill(ctx, f.userA, skillUUID) + if err != nil { + t.Fatalf("load skill by UUID: %v", err) + } + if skillByUUID.ID != "resume-evaluator" { + t.Errorf("skill by UUID mismatch: %+v", skillByUUID) + } + + // Missing skill -> ErrNotFound + _, err = f.loader.LoadSkill(ctx, f.userA, "nonexistent-skill") + if !errors.Is(err, runtime.ErrNotFound) { + t.Errorf("missing skill: got %v, want ErrNotFound", err) + } + + // Cross-org skill -> ErrNotFound + _, err = f.loader.LoadSkill(ctx, f.userOther, "resume-evaluator") + if !errors.Is(err, runtime.ErrNotFound) { + t.Errorf("cross-org skill: got %v, want ErrNotFound", err) + } +} + +/* ── 2. Status Eligibility Tests ─────────────────────────────────────────── */ + +func TestRuntime_StatusEligibility(t *testing.T) { + f := newFixture(t) + ctx := context.Background() + + // 1. Draft Agent + draftMD := `--- +id: draft-agent +name: Draft Agent +status: draft +version: 1 +pages: + - candidates +--- +` + _, _ = f.defRepo.InsertAgent(ctx, f.userA, repo.AgentInsertInput{ + DefinitionID: "draft-agent", + OrgID: f.org1, + Visibility: "organization", + CreatedBy: &f.userA.UserID, + Markdown: draftMD, + Status: "draft", + Version: 1, + Name: "Draft Agent", + Pages: []string{"candidates"}, + }) + + _, err := f.loader.LoadExecutableAgent(ctx, f.userA, "draft-agent") + if !errors.Is(err, runtime.ErrDraftAgent) { + t.Errorf("draft agent: got %v, want ErrDraftAgent", err) + } + + // 2. Archived Agent + archivedMD := `--- +id: archived-agent +name: Archived Agent +status: archived +version: 1 +pages: + - candidates +--- +` + _, _ = f.defRepo.InsertAgent(ctx, f.userA, repo.AgentInsertInput{ + DefinitionID: "archived-agent", + OrgID: f.org1, + Visibility: "organization", + CreatedBy: &f.userA.UserID, + Markdown: archivedMD, + Status: "archived", + Version: 1, + Name: "Archived Agent", + Pages: []string{"candidates"}, + }) + + _, err = f.loader.LoadExecutableAgent(ctx, f.userA, "archived-agent") + if !errors.Is(err, runtime.ErrArchivedAgent) { + t.Errorf("archived agent: got %v, want ErrArchivedAgent", err) + } + + // 3. Published Agent without dependencies -> Success + pubMD := `--- +id: published-agent +name: Published Agent +status: published +version: 1 +pages: + - candidates +--- +` + _, _ = f.defRepo.InsertAgent(ctx, f.userA, repo.AgentInsertInput{ + DefinitionID: "published-agent", + OrgID: f.org1, + Visibility: "organization", + CreatedBy: &f.userA.UserID, + Markdown: pubMD, + Status: "published", + Version: 1, + Name: "Published Agent", + Pages: []string{"candidates"}, + }) + + pubAgent, err := f.loader.LoadExecutableAgent(ctx, f.userA, "published-agent") + if err != nil { + t.Fatalf("load published agent: %v", err) + } + if pubAgent.Status != "published" { + t.Errorf("published agent status: %s", pubAgent.Status) + } + + // 4. Inactive Skill + inactiveSkillMD := `--- +id: inactive-skill +name: Inactive Skill +status: inactive +pages: + - candidates +--- +` + _, _ = f.defRepo.InsertSkill(ctx, f.userA, repo.SkillInsertInput{ + DefinitionID: "inactive-skill", + OrgID: f.org1, + Visibility: "organization", + CreatedBy: &f.userA.UserID, + Markdown: inactiveSkillMD, + Status: "inactive", + Name: "Inactive Skill", + Pages: []string{"candidates"}, + }) + + _, err = f.loader.LoadExecutableSkill(ctx, f.userA, "inactive-skill") + if !errors.Is(err, runtime.ErrInactiveSkill) { + t.Errorf("inactive skill: got %v, want ErrInactiveSkill", err) + } + + // 5. Active Skill -> Success + activeSkillMD := `--- +id: active-skill +name: Active Skill +status: active +pages: + - candidates +--- +` + _, _ = f.defRepo.InsertSkill(ctx, f.userA, repo.SkillInsertInput{ + DefinitionID: "active-skill", + OrgID: f.org1, + Visibility: "organization", + CreatedBy: &f.userA.UserID, + Markdown: activeSkillMD, + Status: "active", + Name: "Active Skill", + Pages: []string{"candidates"}, + }) + + activeSkill, err := f.loader.LoadExecutableSkill(ctx, f.userA, "active-skill") + if err != nil { + t.Fatalf("load active skill: %v", err) + } + if activeSkill.Status != "active" { + t.Errorf("active skill status: %s", activeSkill.Status) + } +} + +/* ── 3. Version Semantics Tests ──────────────────────────────────────────── */ + +func TestRuntime_VersionSemantics(t *testing.T) { + f := newFixture(t) + ctx := context.Background() + + v5MD := `--- +id: v5-agent +name: Version 5 Agent +version: 5 +status: published +pages: + - candidates +--- +` + _, err := f.defRepo.InsertAgent(ctx, f.userA, repo.AgentInsertInput{ + DefinitionID: "v5-agent", + OrgID: f.org1, + Visibility: "organization", + CreatedBy: &f.userA.UserID, + Markdown: v5MD, + Status: "published", + Version: 5, + Name: "Version 5 Agent", + Pages: []string{"candidates"}, + }) + if err != nil { + t.Fatalf("insert v5 agent: %v", err) + } + + agent, err := f.loader.LoadAgent(ctx, f.userA, "v5-agent") + if err != nil { + t.Fatalf("load v5 agent: %v", err) + } + if agent.Version != 5 { + t.Errorf("agent version = %d, want 5", agent.Version) + } +} + +/* ── 4. Dependency Resolution Tests ──────────────────────────────────────── */ + +func TestRuntime_DependencyResolution(t *testing.T) { + f := newFixture(t) + ctx := context.Background() + + // 1. Create active skill 1 + skill1MD := `--- +id: skill-one +name: Skill One +status: active +pages: + - candidates +--- +` + _, _ = f.defRepo.InsertSkill(ctx, f.userA, repo.SkillInsertInput{ + DefinitionID: "skill-one", + OrgID: f.org1, + Visibility: "organization", + CreatedBy: &f.userA.UserID, + Markdown: skill1MD, + Status: "active", + Name: "Skill One", + Pages: []string{"candidates"}, + }) + + // 2. Create active skill 2 (personal for userB) + skill2MD := `--- +id: skill-two +name: Skill Two +status: active +pages: + - candidates +--- +` + _, _ = f.defRepo.InsertSkill(ctx, f.userB, repo.SkillInsertInput{ + DefinitionID: "skill-two", + OrgID: f.org1, + Visibility: "personal", + OwnerUserID: &f.userB.UserID, + CreatedBy: &f.userB.UserID, + Markdown: skill2MD, + Status: "active", + Name: "Skill Two", + Pages: []string{"candidates"}, + }) + + // 3. Create inactive skill 3 + skill3MD := `--- +id: skill-inactive +name: Skill Inactive +status: inactive +pages: + - candidates +--- +` + _, _ = f.defRepo.InsertSkill(ctx, f.userA, repo.SkillInsertInput{ + DefinitionID: "skill-inactive", + OrgID: f.org1, + Visibility: "organization", + CreatedBy: &f.userA.UserID, + Markdown: skill3MD, + Status: "inactive", + Name: "Skill Inactive", + Pages: []string{"candidates"}, + }) + + // Agent with valid and duplicate dependencies + validDepMD := `--- +id: dep-agent +name: Dependency Agent +status: published +version: 1 +pages: + - candidates +skills: + - skill-one + - skill-two + - skill-one +--- +` + _, _ = f.defRepo.InsertAgent(ctx, f.userB, repo.AgentInsertInput{ + DefinitionID: "dep-agent", + OrgID: f.org1, + Visibility: "personal", + OwnerUserID: &f.userB.UserID, + CreatedBy: &f.userB.UserID, + Markdown: validDepMD, + Status: "published", + Version: 1, + Name: "Dependency Agent", + Pages: []string{"candidates"}, + }) + + // User B resolves dep-agent: sees skill-one (org) and skill-two (personal), deduplicates skill-one + loadedAgent, err := f.loader.LoadExecutableAgent(ctx, f.userB, "dep-agent") + if err != nil { + t.Fatalf("load executable agent with deps: %v", err) + } + if len(loadedAgent.ResolvedSkills) != 2 { + t.Fatalf("expected 2 resolved skills (deduplicated), got %d", len(loadedAgent.ResolvedSkills)) + } + if loadedAgent.ResolvedSkills[0].ID != "skill-one" || loadedAgent.ResolvedSkills[1].ID != "skill-two" { + t.Errorf("resolved skill IDs mismatch: %v, %v", loadedAgent.ResolvedSkills[0].ID, loadedAgent.ResolvedSkills[1].ID) + } + + // Agent with missing dependency + missingDepMD := `--- +id: missing-dep-agent +name: Missing Dep Agent +status: published +version: 1 +pages: + - candidates +skills: + - nonexistent-skill +--- +` + _, _ = f.defRepo.InsertAgent(ctx, f.userA, repo.AgentInsertInput{ + DefinitionID: "missing-dep-agent", + OrgID: f.org1, + Visibility: "organization", + CreatedBy: &f.userA.UserID, + Markdown: missingDepMD, + Status: "published", + Version: 1, + Name: "Missing Dep Agent", + Pages: []string{"candidates"}, + }) + + _, err = f.loader.LoadExecutableAgent(ctx, f.userA, "missing-dep-agent") + if !errors.Is(err, runtime.ErrDependencyMissing) { + t.Errorf("missing dep agent: got %v, want ErrDependencyMissing", err) + } + + // Agent with inactive dependency + inactiveDepMD := `--- +id: inactive-dep-agent +name: Inactive Dep Agent +status: published +version: 1 +pages: + - candidates +skills: + - skill-inactive +--- +` + _, _ = f.defRepo.InsertAgent(ctx, f.userA, repo.AgentInsertInput{ + DefinitionID: "inactive-dep-agent", + OrgID: f.org1, + Visibility: "organization", + CreatedBy: &f.userA.UserID, + Markdown: inactiveDepMD, + Status: "published", + Version: 1, + Name: "Inactive Dep Agent", + Pages: []string{"candidates"}, + }) + + _, err = f.loader.LoadExecutableAgent(ctx, f.userA, "inactive-dep-agent") + if !errors.Is(err, runtime.ErrDependencyInactive) { + t.Errorf("inactive dep agent: got %v, want ErrDependencyInactive", err) + } + + // Agent with cross-org dependency: outsider creates agent referencing org1's skill + outsiderAgentMD := `--- +id: outsider-agent +name: Outsider Agent +status: published +version: 1 +pages: + - candidates +skills: + - skill-one +--- +` + _, _ = f.defRepo.InsertAgent(ctx, f.userOther, repo.AgentInsertInput{ + DefinitionID: "outsider-agent", + OrgID: f.org2, + Visibility: "organization", + CreatedBy: &f.userOther.UserID, + Markdown: outsiderAgentMD, + Status: "published", + Version: 1, + Name: "Outsider Agent", + Pages: []string{"candidates"}, + }) + + _, err = f.loader.LoadExecutableAgent(ctx, f.userOther, "outsider-agent") + if !errors.Is(err, runtime.ErrDependencyMissing) { + t.Errorf("outsider cross-org dep: got %v, want ErrDependencyMissing", err) + } +} + +/* ── 5. Executor Boundary Tests ──────────────────────────────────────────── */ + +type testEchoExecutor struct { + lastAgent *runtime.Agent + lastInput runtime.ExecutionInput +} + +func (e *testEchoExecutor) ExecuteAgent(_ context.Context, agent *runtime.Agent, input runtime.ExecutionInput) (*runtime.ExecutionResult, error) { + e.lastAgent = agent + e.lastInput = input + return &runtime.ExecutionResult{ + Success: true, + Output: fmt.Sprintf("executed %s with %s", agent.Name, input.Input), + AgentID: agent.ID, + AgentVersion: agent.Version, + }, nil +} + +func (e *testEchoExecutor) ExecuteSkill(_ context.Context, skill *runtime.Skill, input runtime.ExecutionInput) (*runtime.ExecutionResult, error) { + return &runtime.ExecutionResult{ + Success: true, + Output: fmt.Sprintf("executed skill %s", skill.Name), + }, nil +} + +func TestRuntime_ExecutorBoundary(t *testing.T) { + f := newFixture(t) + ctx := context.Background() + + pubMD := `--- +id: exec-agent +name: Exec Agent +status: published +version: 1 +pages: + - candidates +--- +` + _, _ = f.defRepo.InsertAgent(ctx, f.userA, repo.AgentInsertInput{ + DefinitionID: "exec-agent", + OrgID: f.org1, + Visibility: "organization", + CreatedBy: &f.userA.UserID, + Markdown: pubMD, + Status: "published", + Version: 1, + Name: "Exec Agent", + Pages: []string{"candidates"}, + }) + + // 1. Default engine without configured executor returns ErrExecutorUnavailable + res, err := f.engine.RunAgent(ctx, f.userA, "exec-agent", runtime.ExecutionInput{ + Identity: f.userA, + Input: "Hello agent", + }) + if !errors.Is(err, runtime.ErrExecutorUnavailable) { + t.Errorf("default engine run agent: got %v, want ErrExecutorUnavailable", err) + } + if res.Success { + t.Errorf("default engine must not succeed without executor") + } + + // 2. Custom executor plugged in + echo := &testEchoExecutor{} + customEngine := runtime.NewEngine(f.h.Pool, runtime.WithAgentExecutor(echo), runtime.WithSkillExecutor(echo)) + + resCustom, err := customEngine.RunAgent(ctx, f.userA, "exec-agent", runtime.ExecutionInput{ + Identity: f.userA, + Input: "Hello agent", + }) + if err != nil { + t.Fatalf("custom engine run agent failed: %v", err) + } + if !resCustom.Success || resCustom.Output != "executed Exec Agent with Hello agent" { + t.Errorf("custom executor output mismatch: %+v", resCustom) + } + if echo.lastAgent.ID != "exec-agent" || echo.lastInput.Input != "Hello agent" { + t.Errorf("executor received incorrect arguments: agent=%v, input=%v", echo.lastAgent, echo.lastInput) + } +} + +func TestRuntime_PersonalSkillShadowing(t *testing.T) { + f := newFixture(t) + ctx := context.Background() + + // Org skill + orgSkillMD := `--- +id: shadow-skill +name: Org Shadow Skill +status: active +pages: + - candidates +--- +` + _, _ = f.defRepo.InsertSkill(ctx, f.userA, repo.SkillInsertInput{ + DefinitionID: "shadow-skill", + OrgID: f.org1, + Visibility: "organization", + CreatedBy: &f.userA.UserID, + Markdown: orgSkillMD, + Status: "active", + Name: "Org Shadow Skill", + Pages: []string{"candidates"}, + }) + + // User B's personal skill with the SAME definition_id + persSkillMD := `--- +id: shadow-skill +name: User B Personal Shadow Skill +status: active +pages: + - candidates +--- +` + persRec, _ := f.defRepo.InsertSkill(ctx, f.userB, repo.SkillInsertInput{ + DefinitionID: "shadow-skill", + OrgID: f.org1, + Visibility: "personal", + OwnerUserID: &f.userB.UserID, + CreatedBy: &f.userB.UserID, + Markdown: persSkillMD, + Status: "active", + Name: "User B Personal Shadow Skill", + Pages: []string{"candidates"}, + }) + persUUID := persRec["id"].(string) + + // When User A loads shadow-skill -> gets Org Shadow Skill + skillA, err := f.loader.LoadSkill(ctx, f.userA, "shadow-skill") + if err != nil { + t.Fatalf("userA load shadow skill: %v", err) + } + if skillA.Name != "Org Shadow Skill" { + t.Errorf("userA got %s, want Org Shadow Skill", skillA.Name) + } + + // When User B loads shadow-skill -> gets User B Personal Shadow Skill (shadow precedence) + skillB, err := f.loader.LoadSkill(ctx, f.userB, "shadow-skill") + if err != nil { + t.Fatalf("userB load shadow skill: %v", err) + } + if skillB.Name != "User B Personal Shadow Skill" || skillB.DatabaseID != persUUID { + t.Errorf("userB got %s, want User B Personal Shadow Skill", skillB.Name) + } +} + +func TestRuntime_MalformedMarkdown(t *testing.T) { + f := newFixture(t) + ctx := context.Background() + + // Broken YAML + brokenMD := `--- +id: [broken-id +name: Invalid +--- +` + _, _ = f.h.Pool.Exec(ctx, `INSERT INTO agent_definitions (definition_id, org_id, visibility, created_by, markdown, status, version, name, description, pages) + VALUES ('broken-agent', $1::uuid, 'organization', $2::uuid, $3::text, 'published', 1, 'Broken', '', ARRAY['candidates'])`, + f.org1, f.userA.UserID, brokenMD) + + _, err := f.loader.LoadAgent(ctx, f.userA, "broken-agent") + if !errors.Is(err, runtime.ErrInvalidDefinition) { + t.Errorf("load broken agent: got %v, want ErrInvalidDefinition", err) + } +} + +func TestRuntime_SkillExecution(t *testing.T) { + f := newFixture(t) + ctx := context.Background() + + skillMD := `--- +id: run-skill +name: Runnable Skill +status: active +pages: + - candidates +--- +` + _, _ = f.defRepo.InsertSkill(ctx, f.userA, repo.SkillInsertInput{ + DefinitionID: "run-skill", + OrgID: f.org1, + Visibility: "organization", + CreatedBy: &f.userA.UserID, + Markdown: skillMD, + Status: "active", + Name: "Runnable Skill", + Pages: []string{"candidates"}, + }) + + // Default engine + res, err := f.engine.RunSkill(ctx, f.userA, "run-skill", runtime.ExecutionInput{ + Identity: f.userA, + Input: "skill input", + }) + if !errors.Is(err, runtime.ErrExecutorUnavailable) { + t.Errorf("default engine run skill: got %v, want ErrExecutorUnavailable", err) + } + if res.Success { + t.Errorf("default engine run skill must not succeed") + } + + // Custom executor + echo := &testEchoExecutor{} + customEngine := runtime.NewEngine(f.h.Pool, runtime.WithSkillExecutor(echo)) + resCustom, err := customEngine.RunSkill(ctx, f.userA, "run-skill", runtime.ExecutionInput{ + Identity: f.userA, + }) + if err != nil { + t.Fatalf("custom engine run skill failed: %v", err) + } + if !resCustom.Success || resCustom.Output != "executed skill Runnable Skill" { + t.Errorf("custom skill output mismatch: %+v", resCustom) + } +} diff --git a/go-api/internal/runtime/types.go b/go-api/internal/runtime/types.go new file mode 100644 index 0000000..38f9a5b --- /dev/null +++ b/go-api/internal/runtime/types.go @@ -0,0 +1,103 @@ +package runtime + +import ( + "errors" + "fmt" + + "github.com/krow/krow-backend/go-api/internal/authctx" +) + +// Standard runtime errors. +var ( + ErrNotFound = errors.New("runtime: definition not found") + ErrUnauthorized = errors.New("runtime: unauthorized") + ErrInvalidDefinition = errors.New("runtime: invalid definition") + ErrDraftAgent = errors.New("runtime: agent is in draft status and cannot be executed") + ErrArchivedAgent = errors.New("runtime: agent is archived and cannot be executed") + ErrInactiveSkill = errors.New("runtime: skill is inactive and cannot be executed") + ErrNotExecutable = errors.New("runtime: definition is not eligible for execution") + ErrDependencyMissing = errors.New("runtime: required skill dependency not found") + ErrDependencyInactive = errors.New("runtime: required skill dependency is inactive") + ErrCircularDependency = errors.New("runtime: circular dependency detected in skills") + ErrExecutorUnavailable = errors.New("runtime: AI executor is unavailable (deferred to Phase 5)") +) + +// Agent represents an authored agent prepared for runtime execution. +type Agent struct { + ID string `json:"id"` + DatabaseID string `json:"databaseId"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + Version int `json:"version"` + Visibility string `json:"visibility"` + OwnerUserID *string `json:"ownerUserId,omitempty"` + Pages []string `json:"pages"` + Icon string `json:"icon,omitempty"` + Reasoning string `json:"reasoning,omitempty"` + Trigger string `json:"trigger,omitempty"` + WebSearch bool `json:"webSearch,omitempty"` + Instructions string `json:"instructions"` + Skills []string `json:"skills"` + ResolvedSkills []*Skill `json:"resolvedSkills,omitempty"` + Subagents []string `json:"subagents,omitempty"` + RawMarkdown string `json:"rawMarkdown"` +} + +// Skill represents an authored skill prepared for runtime execution. +type Skill struct { + ID string `json:"id"` + DatabaseID string `json:"databaseId"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + Visibility string `json:"visibility"` + OwnerUserID *string `json:"ownerUserId,omitempty"` + Pages []string `json:"pages"` + Kind string `json:"kind,omitempty"` + Category string `json:"category,omitempty"` + Actions []string `json:"actions,omitempty"` + Triggers []string `json:"triggers,omitempty"` + Prompt *string `json:"prompt,omitempty"` + SkillID *string `json:"skillId,omitempty"` + Body string `json:"body"` + RawMarkdown string `json:"rawMarkdown"` +} + +// ExecutionInput provides caller context and payload to the execution boundary. +type ExecutionInput struct { + Identity authctx.Identity `json:"identity"` + TargetID string `json:"targetId"` + Input string `json:"input"` + Parameters map[string]any `json:"parameters,omitempty"` + Context map[string]any `json:"context,omitempty"` +} + +// ExecutionResult captures the outcome of an execution attempt. +type ExecutionResult struct { + Success bool `json:"success"` + Output string `json:"output,omitempty"` + AgentID string `json:"agentId,omitempty"` + AgentVersion int `json:"agentVersion,omitempty"` + ResolvedSkills []string `json:"resolvedSkills,omitempty"` + Error error `json:"error,omitempty"` +} + +// RuntimeError is a structured error containing context for execution failures. +type RuntimeError struct { + Code string `json:"code"` + Message string `json:"message"` + Target string `json:"target,omitempty"` + Cause error `json:"-"` +} + +func (e *RuntimeError) Error() string { + if e.Target != "" { + return fmt.Sprintf("%s: %s (%s)", e.Code, e.Message, e.Target) + } + return fmt.Sprintf("%s: %s", e.Code, e.Message) +} + +func (e *RuntimeError) Unwrap() error { + return e.Cause +} diff --git a/go-api/internal/seeder/seeder.go b/go-api/internal/seeder/seeder.go new file mode 100644 index 0000000..2575ff4 --- /dev/null +++ b/go-api/internal/seeder/seeder.go @@ -0,0 +1,525 @@ +// Package seeder loads the frontend's demo dataset into PostgreSQL. +// +// Source of truth is the frontend, not this package. `seed/fixtures/seed.json` +// is produced by executing src/api/seed.js through Vite and serialising what it +// exports, so ids, dates, numbers and enum values arrive exactly as the demo +// has them — no transcription step, and nothing to drift. Shift records are the +// one exception: they are generated (see shifts.go) because their dates are +// anchored to now. +// +// Idempotency strategy: EXPLICIT UPSERT inside a single transaction. +// +// Every record's primary key is derived deterministically from its source id +// (uuid v5 over a fixed namespace), so re-running the seeder targets exactly the +// same rows and `ON CONFLICT (id) DO UPDATE` restores each one to its seeded +// values. Records created through the API survive a re-seed. A column the +// fixture does not carry is left as it is. +// +// Shift records are the one collection that is also PRUNED — see +// pruneShiftRecords. Upsert alone cannot converge a rolling window, and shift +// records are the only collection that is a rolling window. +package seeder + +import ( + "context" + "crypto/sha1" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/krow/krow-backend/go-api/internal/domain" + "github.com/krow/krow-backend/go-api/internal/orgctx" +) + +// namespace is a fixed UUID used to derive record ids from source ids. Changing +// it re-keys the entire dataset, so it is a constant, not configuration. +var namespace = [16]byte{ + 0x6b, 0x72, 0x6f, 0x77, 0x2d, 0x73, 0x65, 0x65, + 0x64, 0x2d, 0x76, 0x31, 0x00, 0x00, 0x00, 0x01, +} + +// DeterministicUUID derives a stable v5 UUID from a source id. +func DeterministicUUID(name string) string { + h := sha1.New() + h.Write(namespace[:]) + h.Write([]byte(name)) + var b [16]byte + copy(b[:], h.Sum(nil)) + b[6] = (b[6] & 0x0f) | 0x50 // version 5 + b[8] = (b[8] & 0x3f) | 0x80 // RFC 4122 variant + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +// Fixture is the serialised frontend dataset. +type Fixture struct { + DemoUser map[string]any `json:"demoUser"` + Entities map[string][]map[string]any `json:"entities"` +} + +// Result counts what was written, by entity. +type Result struct { + OrgID string + Counts map[string]int + // Pruned is how many stale shift records this run removed. Reported rather + // than silent: a delete during a seed should never be something you have to + // read the source to discover. + Pruned int +} + +// entityOrder is insertion order, chosen so every foreign key is satisfied by +// the time it is referenced. +var entityOrder = []string{ + "RoleCategory", "Certification", "Badge", "Course", "LearningPath", + "JobPosting", "WorkerProfile", "JobApplication", "AIInterview", "Staff", + "Assignment", "ShiftRecord", "Evidence", "UserActivity", +} + +// entityTable maps a frontend entity name to its table. +var entityTable = map[string]string{ + "RoleCategory": "role_categories", "Certification": "certifications", + "Badge": "badges", "Course": "courses", "LearningPath": "learning_paths", + "JobPosting": "job_postings", "WorkerProfile": "worker_profiles", + "JobApplication": "job_applications", "AIInterview": "ai_interviews", + "Staff": "staff", "Assignment": "assignments", "ShiftRecord": "shift_records", + "Evidence": "evidence", "UserActivity": "user_activity", +} + +// referenceFields are columns holding a source id that must be rewritten to the +// derived UUID. Every one of these is a real reference in the frontend data. +var referenceFields = map[string]bool{ + "job_posting_id": true, "application_id": true, "course_id": true, + "staff_id": true, "assignment_id": true, "worker_profile_id": true, + "interview_id": true, "position_id": true, "candidate_id": true, + "user_id": true, "created_by": true, +} + +// droppedFields are keys the fixture carries that no column exists for and no +// frontend code reads. Dropping them is deliberate and recorded here rather +// than being silent. +// +// _order — a positional index used only while seed.js builds its course list +// (src/api/seed.js:1326). Nothing reads it. +var droppedFields = map[string]bool{"_order": true} + +// Seeder loads a fixture into a database. +type Seeder struct { + pool *pgxpool.Pool + fixture *Fixture + now time.Time +} + +// Load reads a fixture from disk. +func Load(path string) (*Fixture, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read fixture %s: %w", path, err) + } + var f Fixture + if err := json.Unmarshal(raw, &f); err != nil { + return nil, fmt.Errorf("parse fixture %s: %w", path, err) + } + return &f, nil +} + +// New builds a seeder. `now` anchors the generated shift records. +func New(pool *pgxpool.Pool, fixture *Fixture, now time.Time) *Seeder { + return &Seeder{pool: pool, fixture: fixture, now: now} +} + +// Run seeds everything in one transaction: either the whole dataset lands or +// none of it does. +func (s *Seeder) Run(ctx context.Context) (*Result, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback(ctx) }() + + orgID, err := s.upsertOrganization(ctx, tx) + if err != nil { + return nil, err + } + result := &Result{OrgID: orgID, Counts: map[string]int{}} + + n, err := s.upsertUser(ctx, tx, orgID) + if err != nil { + return nil, err + } + result.Counts["User"] = n + + for _, entity := range entityOrder { + records := s.fixture.Entities[entity] + if entity == "ShiftRecord" { + records = BuildShifts(s.now) + } + count, err := s.upsertEntity(ctx, tx, orgID, entity, records) + if err != nil { + return nil, fmt.Errorf("seed %s: %w", entity, err) + } + result.Counts[entity] = count + + if entity == "ShiftRecord" { + pruned, err := s.pruneShiftRecords(ctx, tx, orgID, records) + if err != nil { + return nil, fmt.Errorf("prune ShiftRecord: %w", err) + } + result.Pruned = pruned + } + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return result, nil +} + +// upsertOrganization creates the development organization the whole dataset +// belongs to. See internal/orgctx — this is not a tenant, it is a placeholder +// with a stable id so re-seeding is idempotent. +func (s *Seeder) upsertOrganization(ctx context.Context, tx pgx.Tx) (string, error) { + id := DeterministicUUID("org:" + orgctx.DevOrgSlug) + _, err := tx.Exec(ctx, + `INSERT INTO organizations (id, name, slug) VALUES ($1::uuid, $2, $3::citext) + ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, updated_date = now()`, + id, orgctx.DevOrgName, orgctx.DevOrgSlug) + return id, err +} + +// upsertUser writes the demo user and splits its preferences into their own +// table, as api-contract.md §9 describes. +func (s *Seeder) upsertUser(ctx context.Context, tx pgx.Tx, orgID string) (int, error) { + u := s.fixture.DemoUser + if u == nil { + return 0, nil + } + legacy, _ := u["id"].(string) + id := DeterministicUUID("User:" + legacy) + created := stringOr(u["created_date"], iso(s.now)) + + _, err := tx.Exec(ctx, + `INSERT INTO users (id, legacy_id, org_id, email, full_name, role, account_type, created_date, updated_date) + VALUES ($1::uuid, $2::text, $3::uuid, $4::citext, $5::text, $6::text, $7::text, $8::timestamptz, $8::timestamptz) + ON CONFLICT (id) DO UPDATE SET + email = EXCLUDED.email, full_name = EXCLUDED.full_name, + role = EXCLUDED.role, account_type = EXCLUDED.account_type, + created_date = EXCLUDED.created_date, updated_date = now()`, + id, legacy, orgID, + stringOr(u["email"], ""), stringOr(u["full_name"], ""), + stringOr(u["role"], "admin"), stringOr(u["account_type"], "employer"), created) + if err != nil { + return 0, err + } + + prefs, _ := u["preferences"].(map[string]any) + if prefs == nil { + prefs = map[string]any{} + } + extra := map[string]any{} + for k, v := range prefs { + switch k { + case "owliverDefault", "compactDensity", "emailDigest": + default: + extra[k] = v + } + } + extraJSON, err := json.Marshal(extra) + if err != nil { + return 0, err + } + _, err = tx.Exec(ctx, + `INSERT INTO user_preferences (user_id, owliver_default, compact_density, email_digest, extra) + VALUES ($1::uuid, $2::boolean, $3::boolean, $4::boolean, $5::jsonb) + ON CONFLICT (user_id) DO UPDATE SET + owliver_default = EXCLUDED.owliver_default, + compact_density = EXCLUDED.compact_density, + email_digest = EXCLUDED.email_digest, + extra = EXCLUDED.extra, + updated_date = now()`, + id, boolOr(prefs["owliverDefault"], true), boolOr(prefs["compactDensity"], false), + boolOr(prefs["emailDigest"], true), extraJSON) + if err != nil { + return 0, err + } + return 1, nil +} + +// pruneShiftRecords deletes this organization's shift rows that this run did +// not generate. +// +// WHY THIS EXISTS, and why it is the only place the seeder deletes anything: +// +// A shift's stable id is `shift__`, where NN counts the shift's +// position from the OLDEST end of the rolling 56-day window +// (attendanceSeed.js:190 and shifts.go:167 — the port is faithful, the scheme +// is the problem). That number is a position, not an identity, so it means a +// different date every day the window slides. Measured against a database +// seeded one day earlier: all 114 surviving ids had moved to a different date, +// and one — `shift_marcus_41` — was orphaned, because Marcus works Mon–Fri and +// a Saturday window holds 40 of his shifts rather than 41. +// +// Upsert can rewrite the rows it still generates. It has no way to remove the +// one it no longer generates, so the collection ratchets up to the historical +// maximum and never returns to the size the generator actually produces. +// +// Deleting is safe here in a way it would not be for any other collection: +// ShiftRecord is `Ops: OpList` (api-contract.md §2 — there is no +// POST /shift-records, and U1 in §11 is exactly the question of where these +// records come from), so every row is seeder-owned and no API call can create +// one. shift_records is also a leaf table: no foreign key points at it, so +// nothing cascades. Between them, this delete cannot reach data the seeder did +// not write. +// +// Note what this does NOT do: it invents no records and changes no generated +// value. After it, the collection is exactly what BuildShifts produced for +// s.now — which is what a regenerated rolling window means. +func (s *Seeder) pruneShiftRecords(ctx context.Context, tx pgx.Tx, orgID string, + records []map[string]any) (int, error) { + + // A generation that produced nothing is a bug in BuildShifts, not an + // instruction to empty the table: `id <> ALL('{}')` is true for every row. + // Refuse rather than wipe. + if len(records) == 0 { + return 0, nil + } + + keep := make([]string, 0, len(records)) + for _, rec := range records { + legacy, _ := rec["id"].(string) + if legacy == "" { + return 0, fmt.Errorf("generated shift record has no id") + } + keep = append(keep, DeterministicUUID("ShiftRecord:"+legacy)) + } + + tag, err := tx.Exec(ctx, + `DELETE FROM shift_records WHERE org_id = $1::uuid AND id <> ALL($2::uuid[])`, + orgID, keep) + if err != nil { + return 0, err + } + return int(tag.RowsAffected()), nil +} + +// upsertEntity writes one collection. +func (s *Seeder) upsertEntity(ctx context.Context, tx pgx.Tx, orgID, entity string, records []map[string]any) (int, error) { + table := entityTable[entity] + res, ok := domain.ResourceByTable[table] + if !ok { + return 0, fmt.Errorf("no resource descriptor for table %s", table) + } + for _, rec := range records { + if err := s.upsertRecord(ctx, tx, orgID, entity, res, rec); err != nil { + id, _ := rec["id"].(string) + return 0, fmt.Errorf("record %s: %w", id, err) + } + } + return len(records), nil +} + +func (s *Seeder) upsertRecord(ctx context.Context, tx pgx.Tx, orgID, entity string, + res *domain.Resource, rec map[string]any) error { + + legacy, _ := rec["id"].(string) + if legacy == "" { + return fmt.Errorf("record has no id") + } + + // user_activity's primary key is a GENERATED ALWAYS AS IDENTITY bigint, not + // a uuid, so no explicit id can be supplied for it. Its stable identity is + // legacy_id, which is what the upsert conflicts on instead. + idCol, _ := res.Column("id") + generatedID := idCol.Kind != domain.KindUUID + conflictTarget := "id" + + values := map[string]any{ + "legacy_id": legacy, + "org_id": orgID, + } + if generatedID { + conflictTarget = "legacy_id" + } else { + values["id"] = DeterministicUUID(entity + ":" + legacy) + } + + created := stringOr(rec["created_date"], iso(s.now)) + values["created_date"] = created + if _, hasUpdated := res.Column("updated_date"); hasUpdated { + // The fixture carries updated_date only on job applications, where the + // gap from created_date is what buildHires reads as time-to-hire. + // Everywhere else the column is NOT NULL and the record has never been + // modified, so it takes the creation instant. + values["updated_date"] = stringOr(rec["updated_date"], created) + } + + for key, value := range rec { + switch key { + case "id", "created_date", "updated_date": + continue + } + if droppedFields[key] { + continue + } + col, ok := res.Column(key) + if !ok { + return fmt.Errorf("field %q has no column on %s", key, res.Table) + } + if referenceFields[key] && col.Kind == domain.KindUUID { + str, isStr := value.(string) + if !isStr || str == "" { + values[key] = nil + continue + } + values[key] = DeterministicUUID(referencedEntity(key) + ":" + str) + continue + } + values[key] = value + } + + // Deterministic column order keeps the generated SQL stable. + names := make([]string, 0, len(values)) + for k := range values { + names = append(names, k) + } + sort.Strings(names) + + cols := make([]string, 0, len(names)) + placeholders := make([]string, 0, len(names)) + updates := make([]string, 0, len(names)) + args := make([]any, 0, len(names)) + + for _, name := range names { + col, ok := res.Column(name) + if !ok { + return fmt.Errorf("no column %q on %s", name, res.Table) + } + bound, err := bindSeedValue(*col, values[name]) + if err != nil { + return err + } + args = append(args, bound) + cols = append(cols, name) + placeholders = append(placeholders, fmt.Sprintf("$%d::%s", len(args), col.PGType)) + if name != conflictTarget { + updates = append(updates, fmt.Sprintf("%s = EXCLUDED.%s", name, name)) + } + } + + q := fmt.Sprintf( + "INSERT INTO %s (%s) VALUES (%s) ON CONFLICT (%s) DO UPDATE SET %s", + res.Table, strings.Join(cols, ", "), strings.Join(placeholders, ", "), + conflictTarget, strings.Join(updates, ", ")) + + _, err := tx.Exec(ctx, q, args...) + return err +} + +// referencedEntity says which entity a reference column points at, so the +// derived UUID is built from the same namespace the target was written with. +func referencedEntity(field string) string { + switch field { + case "job_posting_id", "position_id": + return "JobPosting" + case "application_id": + return "JobApplication" + case "course_id": + return "Course" + case "staff_id": + return "Staff" + case "assignment_id": + return "Assignment" + case "worker_profile_id", "candidate_id": + return "WorkerProfile" + case "interview_id": + return "AIInterview" + case "user_id", "created_by": + return "User" + } + return "" +} + +// bindSeedValue converts a fixture value into something pgx can send. It is +// deliberately separate from the repository's binder: the seeder writes +// server-owned columns (id, legacy_id, org_id, created_date) that the API never +// accepts from a client. +func bindSeedValue(col domain.Column, v any) (any, error) { + if v == nil { + return nil, nil + } + switch col.Kind { + case domain.KindTextArray: + switch t := v.(type) { + case []any: + out := make([]string, 0, len(t)) + for _, e := range t { + s, ok := e.(string) + if !ok { + return nil, fmt.Errorf("%s: expected an array of strings", col.Name) + } + out = append(out, s) + } + return out, nil + case []string: + return t, nil + } + return nil, fmt.Errorf("%s: expected an array", col.Name) + + case domain.KindJSON: + raw, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("%s: %w", col.Name, err) + } + return raw, nil + + case domain.KindInt: + switch t := v.(type) { + case float64: + return int64(t), nil + case int: + return int64(t), nil + case int64: + return t, nil + } + return nil, fmt.Errorf("%s: expected a number, got %T", col.Name, v) + + case domain.KindFloat: + switch t := v.(type) { + case float64: + return t, nil + case int: + return float64(t), nil + } + return nil, fmt.Errorf("%s: expected a number, got %T", col.Name, v) + + case domain.KindBool: + if b, ok := v.(bool); ok { + return b, nil + } + return nil, fmt.Errorf("%s: expected a boolean, got %T", col.Name, v) + + default: + if s, ok := v.(string); ok { + return s, nil + } + return nil, fmt.Errorf("%s: expected a string, got %T", col.Name, v) + } +} + +func stringOr(v any, fallback string) string { + if s, ok := v.(string); ok && s != "" { + return s + } + return fallback +} + +func boolOr(v any, fallback bool) bool { + if b, ok := v.(bool); ok { + return b + } + return fallback +} diff --git a/go-api/internal/seeder/seeder_test.go b/go-api/internal/seeder/seeder_test.go new file mode 100644 index 0000000..5077f90 --- /dev/null +++ b/go-api/internal/seeder/seeder_test.go @@ -0,0 +1,300 @@ +package seeder_test + +import ( + "context" + "testing" + "time" + + "github.com/krow/krow-backend/go-api/internal/seeder" + "github.com/krow/krow-backend/go-api/internal/testutil" +) + +// tableFor maps the fixture's entity names onto their tables, so the counts +// asserted below come from the frontend's own data rather than from literals. +var tableFor = map[string]string{ + "JobPosting": "job_postings", "JobApplication": "job_applications", + "AIInterview": "ai_interviews", "Staff": "staff", "WorkerProfile": "worker_profiles", + "Course": "courses", "Badge": "badges", "LearningPath": "learning_paths", + "Certification": "certifications", "RoleCategory": "role_categories", + "UserActivity": "user_activity", "Evidence": "evidence", "Assignment": "assignments", +} + +func count(t *testing.T, h *testutil.Harness, table string) int { + t.Helper() + var n int + if err := h.Pool.QueryRow(context.Background(), "SELECT count(*) FROM "+table).Scan(&n); err != nil { + t.Fatalf("count %s: %v", table, err) + } + return n +} + +// TestSeedMatchesFixtureCounts checks every entity against the fixture rather +// than against a hardcoded headline number. +func TestSeedMatchesFixtureCounts(t *testing.T) { + h := testutil.New(t) + fx := testutil.Fixture(t) + + for entity, table := range tableFor { + want := len(fx.Entities[entity]) + if got := count(t, h, table); got != want { + t.Errorf("%s: seeded %d rows, fixture has %d", table, got, want) + } + } +} + +// TestSeedRegressionAnchors pins the figures the demo dataset is built to +// produce. These are verified against the source, not assumed: the prompt's +// "6 postings / 22 applications" is 6 *active* postings and 24 applications. +func TestSeedRegressionAnchors(t *testing.T) { + h := testutil.New(t) + ctx := context.Background() + + var active int + if err := h.Pool.QueryRow(ctx, + "SELECT count(*) FROM job_postings WHERE status = 'active'").Scan(&active); err != nil { + t.Fatal(err) + } + if active != 6 { + t.Errorf("active postings = %d, want 6", active) + } + if total := count(t, h, "job_postings"); total != 8 { + t.Errorf("job postings = %d, want 8 (6 active, 1 paused, 1 closed)", total) + } + if total := count(t, h, "job_applications"); total != 24 { + t.Errorf("applications = %d, want 24", total) + } + + var scored int + var avgScored float64 + if err := h.Pool.QueryRow(ctx, + "SELECT count(*), coalesce(avg(ai_score), 0) FROM job_applications WHERE ai_score > 0"). + Scan(&scored, &avgScored); err != nil { + t.Fatal(err) + } + if scored != 9 { + t.Errorf("scored applications = %d, want 9", scored) + } + if avgScored < 75.95 || avgScored > 76.05 { + t.Errorf("average scored ai_score = %.2f, want 76.0", avgScored) + } + + var hires int + var avgHire float64 + if err := h.Pool.QueryRow(ctx, + "SELECT count(*), coalesce(avg(ai_score), 0) FROM staff").Scan(&hires, &avgHire); err != nil { + t.Fatal(err) + } + if hires != 3 { + t.Errorf("hires = %d, want 3", hires) + } + if avgHire < 94.0 || avgHire > 94.5 { + t.Errorf("average hire ai_score = %.2f, want ~94.3", avgHire) + } +} + +// TestSeedPreservesSourceValues compares stored rows field-by-field against the +// fixture, rather than trusting that the counts lining up means the data did. +func TestSeedPreservesSourceValues(t *testing.T) { + h := testutil.New(t) + fx := testutil.Fixture(t) + ctx := context.Background() + + for _, want := range fx.Entities["JobPosting"] { + legacy := want["id"].(string) + var title, status, company, roleCategory, createdDate string + var payMin, payMax int + err := h.Pool.QueryRow(ctx, ` + SELECT title, status::text, company, role_category, pay_range_min, pay_range_max, + to_char(created_date AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') + FROM job_postings WHERE legacy_id = $1`, legacy). + Scan(&title, &status, &company, &roleCategory, &payMin, &payMax, &createdDate) + if err != nil { + t.Fatalf("%s: %v", legacy, err) + } + if title != want["title"] { + t.Errorf("%s title = %q, want %q", legacy, title, want["title"]) + } + if status != want["status"] { + t.Errorf("%s status = %q, want %q", legacy, status, want["status"]) + } + if createdDate != want["created_date"] { + t.Errorf("%s created_date = %q, want %q", legacy, createdDate, want["created_date"]) + } + if v, ok := want["pay_range_min"].(float64); ok && payMin != int(v) { + t.Errorf("%s pay_range_min = %d, want %d", legacy, payMin, int(v)) + } + if v, ok := want["pay_range_max"].(float64); ok && payMax != int(v) { + t.Errorf("%s pay_range_max = %d, want %d", legacy, payMax, int(v)) + } + } + + // Applications carry updated_date in the source, and the gap from + // created_date is what buildHires reads as time-to-hire. + for _, want := range fx.Entities["JobApplication"] { + legacy := want["id"].(string) + var name, email, status, created, updated string + var score int + err := h.Pool.QueryRow(ctx, ` + SELECT applicant_name, email::text, status::text, ai_score, + to_char(created_date AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'), + to_char(updated_date AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') + FROM job_applications WHERE legacy_id = $1`, legacy). + Scan(&name, &email, &status, &score, &created, &updated) + if err != nil { + t.Fatalf("%s: %v", legacy, err) + } + if name != want["applicant_name"] { + t.Errorf("%s applicant_name = %q, want %q", legacy, name, want["applicant_name"]) + } + if status != want["status"] { + t.Errorf("%s status = %q, want %q", legacy, status, want["status"]) + } + if created != want["created_date"] { + t.Errorf("%s created_date = %q, want %q", legacy, created, want["created_date"]) + } + if updated != want["updated_date"] { + t.Errorf("%s updated_date = %q, want %q", legacy, updated, want["updated_date"]) + } + if v, ok := want["ai_score"].(float64); ok && score != int(v) { + t.Errorf("%s ai_score = %d, want %d", legacy, score, int(v)) + } + } +} + +// TestSeedIsIdempotent runs the seeder a second time over an already-seeded +// database and expects every count and every id to be unchanged. +func TestSeedIsIdempotent(t *testing.T) { + h := testutil.New(t) + fx := testutil.Fixture(t) + ctx := context.Background() + + before := map[string]int{} + for _, table := range tableFor { + before[table] = count(t, h, table) + } + var idsBefore string + if err := h.Pool.QueryRow(ctx, + "SELECT coalesce(string_agg(id::text, ',' ORDER BY id), '') FROM job_applications"). + Scan(&idsBefore); err != nil { + t.Fatal(err) + } + + if _, err := seeder.New(h.Pool, fx, h.Now).Run(ctx); err != nil { + t.Fatalf("second seed: %v", err) + } + + for _, table := range tableFor { + if got := count(t, h, table); got != before[table] { + t.Errorf("%s: %d rows after re-seed, %d before — the seeder duplicated rows", + table, got, before[table]) + } + } + var idsAfter string + if err := h.Pool.QueryRow(ctx, + "SELECT coalesce(string_agg(id::text, ',' ORDER BY id), '') FROM job_applications"). + Scan(&idsAfter); err != nil { + t.Fatal(err) + } + if idsAfter != idsBefore { + t.Error("application ids changed across a re-seed; keys are not deterministic") + } +} + +// TestSeedRelationships checks that every reference was rewritten to a real row. +func TestSeedRelationships(t *testing.T) { + h := testutil.New(t) + ctx := context.Background() + + dangling := []struct{ name, query string }{ + {"applications without a posting", + `SELECT count(*) FROM job_applications a + LEFT JOIN job_postings p ON p.id = a.job_posting_id WHERE p.id IS NULL`}, + {"interviews without an application", + `SELECT count(*) FROM ai_interviews i + LEFT JOIN job_applications a ON a.id = i.application_id WHERE a.id IS NULL`}, + {"staff without an application", + `SELECT count(*) FROM staff s LEFT JOIN job_applications a ON a.id = s.application_id + WHERE s.application_id IS NOT NULL AND a.id IS NULL`}, + {"shifts without staff", + `SELECT count(*) FROM shift_records r LEFT JOIN staff s ON s.id = r.staff_id + WHERE r.staff_id IS NOT NULL AND s.id IS NULL`}, + {"evidence without a course", + `SELECT count(*) FROM evidence e LEFT JOIN courses c ON c.id = e.course_id + WHERE e.course_id IS NOT NULL AND c.id IS NULL`}, + } + for _, d := range dangling { + var n int + if err := h.Pool.QueryRow(ctx, d.query).Scan(&n); err != nil { + t.Fatalf("%s: %v", d.name, err) + } + if n != 0 { + t.Errorf("%s: %d", d.name, n) + } + } + + // interview_id is a soft reference on purpose: the source contains one + // dangling value (app_devon -> int_devon), and preserving it is the point. + var set, resolve int + if err := h.Pool.QueryRow(ctx, + `SELECT (SELECT count(*) FROM job_applications WHERE interview_id IS NOT NULL), + (SELECT count(*) FROM job_applications a JOIN ai_interviews i ON i.id = a.interview_id)`). + Scan(&set, &resolve); err != nil { + t.Fatal(err) + } + if set != 5 { + t.Errorf("applications carrying interview_id = %d, want 5", set) + } + if resolve != 4 { + t.Errorf("resolvable interview_id = %d, want 4 (int_devon dangles in the source)", resolve) + } +} + +// TestSeedOrganizationScope checks every seeded row belongs to the development +// organization, so organization scoping has something real to filter on. +func TestSeedOrganizationScope(t *testing.T) { + h := testutil.New(t) + ctx := context.Background() + for _, table := range tableFor { + var wrong int + if err := h.Pool.QueryRow(ctx, + "SELECT count(*) FROM "+table+" WHERE org_id IS DISTINCT FROM $1::uuid", h.OrgID). + Scan(&wrong); err != nil { + t.Fatalf("%s: %v", table, err) + } + if wrong != 0 { + t.Errorf("%s: %d rows outside the development organization", table, wrong) + } + } +} + +// TestDeterministicUUID pins the key derivation: the same source id must always +// produce the same key, or a re-seed would duplicate every row. +func TestDeterministicUUID(t *testing.T) { + a := seeder.DeterministicUUID("JobPosting:job_chef") + b := seeder.DeterministicUUID("JobPosting:job_chef") + if a != b { + t.Fatalf("not deterministic: %s != %s", a, b) + } + if c := seeder.DeterministicUUID("JobPosting:job_security"); c == a { + t.Fatal("distinct source ids produced the same key") + } + if len(a) != 36 || a[14] != '5' { + t.Errorf("expected a v5 UUID, got %q", a) + } +} + +// TestSeedDoesNotDependOnWallClock: seeding twice with the same anchor must +// produce identical shift records. +func TestSeedShiftsStableForAnchor(t *testing.T) { + anchor := time.Date(2026, 8, 21, 15, 0, 0, 0, time.Local) + a := seeder.BuildShifts(anchor) + b := seeder.BuildShifts(anchor) + if len(a) != len(b) { + t.Fatalf("shift count differs between runs: %d vs %d", len(a), len(b)) + } + for i := range a { + if a[i]["id"] != b[i]["id"] || a[i]["created_date"] != b[i]["created_date"] { + t.Fatalf("shift %d differs between runs", i) + } + } +} diff --git a/go-api/internal/seeder/shifts.go b/go-api/internal/seeder/shifts.go new file mode 100644 index 0000000..d6b7c30 --- /dev/null +++ b/go-api/internal/seeder/shifts.go @@ -0,0 +1,215 @@ +package seeder + +import ( + "fmt" + "math" + "sort" + "time" +) + +// A port of src/api/attendanceSeed.js. +// +// Ported rather than snapshotted because this is the one collection whose dates +// are anchored to *now* rather than to a fixed calendar. `dataResolver.inPeriod` +// windows every collection on created_date, so "attendance last week" has to +// mean last week on the day the seeder runs. A frozen JSON snapshot would read +// as permanently empty a fortnight later. +// +// Nothing here is random. The distribution is deterministic given the date the +// seeder runs, so the same day always produces the same figures and the +// regression tests can assert against them: +// +// Marco — the control: reliable, weekend event overtime +// Marcus — attendance degrading over the last fortnight (the anomaly) +// Antoine — present throughout, overtime climbing week on week (the trend) + +const windowDays = 56 + +type rosterEntry struct { + staffID, workerName, workerEmail string + jobPostingID, role, roleCategory string + weekdays []time.Weekday + startHour int + scheduledHours float64 +} + +var roster = []rosterEntry{ + { + staffID: "staff_marco", workerName: "Marco Rivera", workerEmail: "marco.rivera@email.com", + jobPostingID: "job_bartender_corp", role: "Experienced Bartender – Corporate Events", + roleCategory: "Bartender", + // Wed–Sat: corporate events run late in the week. + weekdays: []time.Weekday{time.Wednesday, time.Thursday, time.Friday, time.Saturday}, + startHour: 16, scheduledHours: 8, + }, + { + staffID: "staff_marcus", workerName: "Marcus Williams", workerEmail: "marcus.w@email.com", + jobPostingID: "job_security", role: "Event Security Officer", roleCategory: "Security", + // Mon–Fri: a fixed rota, which is what makes the recent absences stand + // out rather than read as an irregular schedule. + weekdays: []time.Weekday{time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday}, + startHour: 14, scheduledHours: 8, + }, + { + staffID: "staff_antoine", workerName: "Chef Antoine Dubois", workerEmail: "antoine.dubois@email.com", + jobPostingID: "job_chef", role: "Executive Chef – Catering", roleCategory: "Chef", + // Tue–Sat: kitchen service. + weekdays: []time.Weekday{time.Tuesday, time.Wednesday, time.Thursday, time.Friday, time.Saturday}, + startHour: 12, scheduledHours: 9, + }, +} + +type behaviour struct { + status string + minutesLate int + overtime float64 + notes string +} + +// behaviourFor mirrors the BEHAVIOUR map. `i` counts back from the most recent +// shift, so "the last fortnight" stays a range of small indices as the window +// rolls forward. +func behaviourFor(staffID string, i int, weekday time.Weekday) behaviour { + switch staffID { + case "staff_marco": + b := behaviour{status: "present"} + if i == 14 { + b.status, b.minutesLate = "late", 9 + } + // Friday and Saturday events overrun; midweek ones do not. + if weekday == time.Friday || weekday == time.Saturday { + b.overtime = 1 + } + return b + + case "staff_marcus": + switch i { + case 2, 7: + return behaviour{status: "absent", notes: "Called in sick"} + case 4: + return behaviour{status: "no_show", notes: "No contact"} + case 1: + return behaviour{status: "late", minutesLate: 24} + case 5: + return behaviour{status: "late", minutesLate: 16} + case 9: + return behaviour{status: "late", minutesLate: 12} + case 26: + return behaviour{status: "late", minutesLate: 7} + } + return behaviour{status: "present"} + + case "staff_antoine": + weekIndex := i / 5 + busy := weekday == time.Thursday || weekday == time.Friday || weekday == time.Saturday + b := behaviour{status: "present"} + if busy { + b.overtime = math.Max(0.5, round1(3.5-float64(weekIndex)*0.45)) + } + return b + } + return behaviour{status: "present"} +} + +// round1 and round2 reproduce JavaScript's Math.round, which rounds halves away +// from zero — the same rule as Go's math.Round. +func round1(n float64) float64 { return math.Round(n*10) / 10 } +func round2(n float64) float64 { return math.Round(n*100) / 100 } + +// daysAgo is `n` days back at a given local hour. +// +// Local rather than UTC because a shift belongs to the day it was worked in the +// place it was worked, and periodRange windows on local day boundaries too. +func daysAgo(now time.Time, n, hour int) time.Time { + d := now.AddDate(0, 0, -n) + return time.Date(d.Year(), d.Month(), d.Day(), hour, 0, 0, 0, now.Location()) +} + +func containsWeekday(set []time.Weekday, w time.Weekday) bool { + for _, x := range set { + if x == w { + return true + } + } + return false +} + +// shiftOffsets is every day offset in the window on which this worker is rostered. +func shiftOffsets(now time.Time, weekdays []time.Weekday) []int { + var offsets []int + for offset := 0; offset <= windowDays; offset++ { + if containsWeekday(weekdays, daysAgo(now, offset, 0).Weekday()) { + offsets = append(offsets, offset) + } + } + return offsets +} + +const isoMillis = "2006-01-02T15:04:05.000Z" + +func iso(t time.Time) string { return t.UTC().Format(isoMillis) } + +// BuildShifts generates the shift records for a given instant, most recent first. +func BuildShifts(now time.Time) []map[string]any { + records := make([]map[string]any, 0, 128) + + for _, w := range roster { + offsets := shiftOffsets(now, w.weekdays) + for i, offset := range offsets { + scheduledStart := daysAgo(now, offset, w.startHour) + weekday := scheduledStart.Weekday() + scheduledEnd := scheduledStart.Add(time.Duration(w.scheduledHours * float64(time.Hour))) + + b := behaviourFor(w.staffID, i, weekday) + worked := b.status != "absent" && b.status != "no_show" + + var actualStart, actualEnd any + endForUpdated := scheduledEnd + if worked { + actualStart = iso(scheduledStart.Add(time.Duration(b.minutesLate) * time.Minute)) + ae := scheduledEnd.Add(time.Duration(b.overtime * float64(time.Hour))) + actualEnd, endForUpdated = iso(ae), ae + } + + actualHours, overtimeHours, minutesLate := 0.0, 0.0, 0 + if worked { + actualHours = round2(w.scheduledHours - float64(b.minutesLate)/60 + b.overtime) + overtimeHours = round1(b.overtime) + minutesLate = b.minutesLate + } + + records = append(records, map[string]any{ + "id": fmt.Sprintf("shift_%s_%02d", w.staffID[len("staff_"):], len(offsets)-i), + "staff_id": w.staffID, + "worker_name": w.workerName, + "worker_email": w.workerEmail, + "job_posting_id": w.jobPostingID, + "role": w.role, + "role_category": w.roleCategory, + "shift_date": scheduledStart.Format("2006-01-02"), + "scheduled_start": iso(scheduledStart), + "scheduled_end": iso(scheduledEnd), + "scheduled_hours": w.scheduledHours, + "actual_start": actualStart, + "actual_end": actualEnd, + "actual_hours": actualHours, + "overtime_hours": overtimeHours, + "minutes_late": minutesLate, + "status": b.status, + "notes": b.notes, + // Load-bearing: dataResolver windows every collection on + // created_date, so a shift's created date IS the instant it + // was worked. + "created_date": iso(scheduledStart), + "updated_date": iso(endForUpdated), + }) + } + } + + // Most recent first, matching the -created_date order every other + // collection is listed in. + sort.SliceStable(records, func(a, b int) bool { + return records[a]["created_date"].(string) > records[b]["created_date"].(string) + }) + return records +} diff --git a/go-api/internal/seeder/shifts_convergence_test.go b/go-api/internal/seeder/shifts_convergence_test.go new file mode 100644 index 0000000..32bc313 --- /dev/null +++ b/go-api/internal/seeder/shifts_convergence_test.go @@ -0,0 +1,178 @@ +package seeder_test + +import ( + "context" + "testing" + "time" + + "github.com/krow/krow-backend/go-api/internal/seeder" + "github.com/krow/krow-backend/go-api/internal/testutil" +) + +// The shift collection is a rolling 56-day window, and its stable id encodes a +// shift's POSITION in that window rather than its identity. Seed on Friday and +// re-seed on Saturday and every id means a different date; one of them — +// Marcus's, because he works Mon–Fri and a Saturday window holds one fewer of +// his shifts — is no longer generated at all. +// +// Upsert cannot express that. These tests pin the behaviour that can: after any +// seed, shift_records holds exactly what BuildShifts produced for that instant, +// and nothing left over from a previous run. + +func shiftCount(t *testing.T, h *testutil.Harness) int { + t.Helper() + var n int + if err := h.Pool.QueryRow(context.Background(), + "SELECT count(*) FROM shift_records").Scan(&n); err != nil { + t.Fatalf("count shift_records: %v", err) + } + return n +} + +// TestReseedOnALaterDayLeavesNoStaleShifts is the regression itself: the +// database was seeded on one day, the frontend regenerates on the next, and the +// two must still describe the same collection. +func TestReseedOnALaterDayLeavesNoStaleShifts(t *testing.T) { + h := testutil.New(t) + fx := testutil.Fixture(t) + ctx := context.Background() + + // A Friday, then the Saturday after it — the exact pair that orphaned + // shift_marcus_41 in the live database. + friday := time.Date(2026, 8, 21, 12, 0, 0, 0, time.Local) + saturday := friday.AddDate(0, 0, 1) + + if _, err := seeder.New(h.Pool, fx, friday).Run(ctx); err != nil { + t.Fatalf("seed on the Friday: %v", err) + } + fridayRows := shiftCount(t, h) + if want := len(seeder.BuildShifts(friday)); fridayRows != want { + t.Fatalf("after the Friday seed: %d rows, generator produced %d", fridayRows, want) + } + + result, err := seeder.New(h.Pool, fx, saturday).Run(ctx) + if err != nil { + t.Fatalf("re-seed on the Saturday: %v", err) + } + + generated := seeder.BuildShifts(saturday) + if got := shiftCount(t, h); got != len(generated) { + t.Errorf("after re-seeding a day later: %d rows, but the generator produced %d "+ + "— %d stale record(s) survived the re-seed", got, len(generated), got-len(generated)) + } + if result.Pruned != fridayRows-len(generated) { + t.Errorf("Pruned = %d, want %d", result.Pruned, fridayRows-len(generated)) + } + + // Every surviving row must be one this run generated, holding this run's + // date for that id — not the previous run's. + want := map[string]string{} + for _, rec := range generated { + want[rec["id"].(string)] = rec["shift_date"].(string) + } + rows, err := h.Pool.Query(ctx, "SELECT legacy_id, shift_date::text FROM shift_records") + if err != nil { + t.Fatal(err) + } + defer rows.Close() + for rows.Next() { + var legacy, date string + if err := rows.Scan(&legacy, &date); err != nil { + t.Fatal(err) + } + switch expected, generatedNow := want[legacy]; { + case !generatedNow: + t.Errorf("%s is in the database but was not generated for this instant", legacy) + case expected != date: + t.Errorf("%s holds %s, but this run generated it as %s", legacy, date, expected) + } + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } +} + +// TestReseedIsConvergentAcrossAWeek walks a whole week, so the assertion does +// not depend on the one day pair that happened to expose the bug. Every day of +// the week changes the roster composition differently. +func TestReseedIsConvergentAcrossAWeek(t *testing.T) { + h := testutil.New(t) + fx := testutil.Fixture(t) + ctx := context.Background() + + day := time.Date(2026, 8, 17, 9, 0, 0, 0, time.Local) // a Monday + for i := 0; i < 7; i++ { + now := day.AddDate(0, 0, i) + if _, err := seeder.New(h.Pool, fx, now).Run(ctx); err != nil { + t.Fatalf("seed on %s: %v", now.Weekday(), err) + } + want := len(seeder.BuildShifts(now)) + if got := shiftCount(t, h); got != want { + t.Errorf("%s %s: %d rows, generator produced %d", + now.Weekday(), now.Format("2006-01-02"), got, want) + } + } +} + +// Re-seeding the same instant twice must still change nothing — the prune must +// not delete rows it just wrote. +func TestReseedSameInstantPrunesNothing(t *testing.T) { + h := testutil.New(t) + fx := testutil.Fixture(t) + ctx := context.Background() + + before := shiftCount(t, h) + result, err := seeder.New(h.Pool, fx, h.Now).Run(ctx) + if err != nil { + t.Fatalf("re-seed: %v", err) + } + if result.Pruned != 0 { + t.Errorf("re-seeding the same instant pruned %d record(s), want 0", result.Pruned) + } + if got := shiftCount(t, h); got != before { + t.Errorf("shift_records went from %d to %d rows on an identical re-seed", before, got) + } +} + +// The prune is scoped to the organization being seeded. Another organization's +// shift records are none of its business — and once authentication lands, that +// is the difference between a re-seed and an incident. +func TestPruneIsScopedToTheSeededOrganization(t *testing.T) { + h := testutil.New(t) + fx := testutil.Fixture(t) + ctx := context.Background() + + var otherOrg string + if err := h.Pool.QueryRow(ctx, + `INSERT INTO organizations (name, slug) VALUES ('Other', 'other-org') RETURNING id::text`). + Scan(&otherOrg); err != nil { + t.Fatal(err) + } + // Copy one of this organization's shifts into the other one, with an id and + // legacy_id no generation will ever produce. + if _, err := h.Pool.Exec(ctx, + `INSERT INTO shift_records (org_id, legacy_id, staff_id, worker_name, worker_email, + job_posting_id, role, role_category, shift_date, scheduled_start, scheduled_end, + scheduled_hours, actual_start, actual_end, actual_hours, overtime_hours, + minutes_late, status, notes, created_date, updated_date) + SELECT $1::uuid, 'shift_other_99', staff_id, worker_name, worker_email, + job_posting_id, role, role_category, shift_date, scheduled_start, scheduled_end, + scheduled_hours, actual_start, actual_end, actual_hours, overtime_hours, + minutes_late, status, notes, created_date, updated_date + FROM shift_records LIMIT 1`, otherOrg); err != nil { + t.Fatal(err) + } + + if _, err := seeder.New(h.Pool, fx, h.Now.AddDate(0, 0, 1)).Run(ctx); err != nil { + t.Fatalf("re-seed: %v", err) + } + + var survived int + if err := h.Pool.QueryRow(ctx, + "SELECT count(*) FROM shift_records WHERE org_id = $1::uuid", otherOrg).Scan(&survived); err != nil { + t.Fatal(err) + } + if survived != 1 { + t.Errorf("the other organization's shift record was pruned: %d survived, want 1", survived) + } +} diff --git a/go-api/internal/seeder/shifts_test.go b/go-api/internal/seeder/shifts_test.go new file mode 100644 index 0000000..91b8e58 --- /dev/null +++ b/go-api/internal/seeder/shifts_test.go @@ -0,0 +1,134 @@ +package seeder_test + +import ( + "testing" + "time" + + "github.com/krow/krow-backend/go-api/internal/seeder" +) + +// The shift generator is a port of src/api/attendanceSeed.js. These tests pin +// the distribution that module's own documentation describes, so a drift in the +// port shows up as a failing assertion rather than as quietly different +// attendance figures. + +func shiftsFor(anchor time.Time, email string) []map[string]any { + var out []map[string]any + for _, r := range seeder.BuildShifts(anchor) { + if r["worker_email"] == email { + out = append(out, r) + } + } + return out +} + +func TestShiftDistributionMatchesSource(t *testing.T) { + anchor := time.Date(2026, 8, 21, 12, 0, 0, 0, time.Local) + all := seeder.BuildShifts(anchor) + + counts := map[string]int{} + for _, r := range all { + counts[r["status"].(string)]++ + } + + // Marcus alone supplies the attendance anomaly: two absences, one no-show + // and three late arrivals in the recent window, plus one older late. + if counts["absent"] != 2 { + t.Errorf("absent = %d, want 2", counts["absent"]) + } + if counts["no_show"] != 1 { + t.Errorf("no_show = %d, want 1", counts["no_show"]) + } + // Marcus i=1,5,9,26 plus Marco i=14. + if counts["late"] != 5 { + t.Errorf("late = %d, want 5", counts["late"]) + } + if counts["present"] == 0 { + t.Error("no present shifts generated") + } +} + +func TestShiftRosterIsThreePeople(t *testing.T) { + anchor := time.Date(2026, 8, 21, 12, 0, 0, 0, time.Local) + emails := map[string]bool{} + for _, r := range seeder.BuildShifts(anchor) { + emails[r["worker_email"].(string)] = true + } + if len(emails) != 3 { + t.Fatalf("roster has %d people, want 3 (one per hire)", len(emails)) + } +} + +// A missed shift is zero hours worked, not a short one — and the schema's +// shift_records_absence_has_no_hours constraint depends on it. +func TestAbsencesHaveNoHours(t *testing.T) { + anchor := time.Date(2026, 8, 21, 12, 0, 0, 0, time.Local) + for _, r := range seeder.BuildShifts(anchor) { + status := r["status"].(string) + if status != "absent" && status != "no_show" { + continue + } + if r["actual_hours"].(float64) != 0 { + t.Errorf("%s: %s shift has actual_hours %v", r["id"], status, r["actual_hours"]) + } + if r["minutes_late"].(int) != 0 { + t.Errorf("%s: %s shift has minutes_late %v", r["id"], status, r["minutes_late"]) + } + if r["actual_start"] != nil || r["actual_end"] != nil { + t.Errorf("%s: %s shift has actual timestamps", r["id"], status) + } + } +} + +// Antoine's overtime climbs week on week — a trend rather than a spike. It is +// the overtime anomaly the analytics are shaped to surface. +func TestAntoineOvertimeClimbs(t *testing.T) { + anchor := time.Date(2026, 8, 21, 12, 0, 0, 0, time.Local) + shifts := shiftsFor(anchor, "antoine.dubois@email.com") + if len(shifts) == 0 { + t.Fatal("no shifts generated for Antoine") + } + + // BuildShifts returns most-recent-first, so recent overtime should exceed + // the overtime from the far end of the window. + var recent, older float64 + for i, r := range shifts { + ot := r["overtime_hours"].(float64) + if i < 10 { + recent += ot + } + if i >= len(shifts)-10 { + older += ot + } + } + if recent <= older { + t.Errorf("overtime is not climbing: recent 10 = %.1fh, oldest 10 = %.1fh", recent, older) + } +} + +// created_date is the instant the shift was worked. dataResolver windows every +// collection on it, so a shift dated anywhere else would vanish from every +// period reading. +func TestShiftCreatedDateIsTheShiftInstant(t *testing.T) { + anchor := time.Date(2026, 8, 21, 12, 0, 0, 0, time.Local) + for _, r := range seeder.BuildShifts(anchor) { + if r["created_date"] != r["scheduled_start"] { + t.Fatalf("%s: created_date %v is not the scheduled start %v", + r["id"], r["created_date"], r["scheduled_start"]) + } + } +} + +// The window rolls forward with the anchor: shifts must stay recent relative to +// whenever the seeder runs, which is the whole reason this is a port rather +// than a frozen snapshot. +func TestShiftWindowFollowsTheAnchor(t *testing.T) { + early := seeder.BuildShifts(time.Date(2026, 3, 1, 12, 0, 0, 0, time.Local)) + late := seeder.BuildShifts(time.Date(2026, 8, 21, 12, 0, 0, 0, time.Local)) + if early[0]["created_date"] == late[0]["created_date"] { + t.Fatal("shift dates did not move with the anchor") + } + if got := late[0]["created_date"].(string)[:4]; got != "2026" { + t.Errorf("most recent shift is dated %s", got) + } +} diff --git a/go-api/internal/service/definitions.go b/go-api/internal/service/definitions.go new file mode 100644 index 0000000..6a63cf2 --- /dev/null +++ b/go-api/internal/service/definitions.go @@ -0,0 +1,451 @@ +package service + +import ( + "context" + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/krow/krow-backend/go-api/internal/authctx" + "github.com/krow/krow-backend/go-api/internal/definition" + "github.com/krow/krow-backend/go-api/internal/domain" + "github.com/krow/krow-backend/go-api/internal/repo" +) + +// allowedDefinitionFilters names the accepted query parameters for definition collections. +var allowedDefinitionFilters = map[string]bool{ + "visibility": true, + "status": true, + "definition_id": true, + "sort": true, + "limit": true, + "offset": true, +} + +// DefinitionsService manages authored Agent and Skill definitions. +type DefinitionsService struct { + repo *repo.DefinitionsRepo +} + +// NewDefinitions builds a definitions service over a repository. +func NewDefinitions(db repo.Querier) *DefinitionsService { + return &DefinitionsService{repo: repo.NewDefinitionsRepo(db)} +} + +// ParseListParams validates query parameters for listing definitions. +func (s *DefinitionsService) ParseListParams(q url.Values) (repo.DefinitionListParams, error) { + p := repo.DefinitionListParams{ + Limit: 100, + Sort: "created_date", + Desc: true, + } + + for name := range q { + if !allowedDefinitionFilters[name] { + return p, domain.Invalid(fmt.Sprintf("unknown filter field %q", name)) + } + } + + if raw := q.Get("visibility"); raw != "" { + if raw != "personal" && raw != "organization" { + return p, domain.Invalid("visibility must be one of: personal, organization") + } + p.Visibility = raw + } + + if raw := q.Get("status"); raw != "" { + p.Status = raw + } + + if raw := q.Get("definition_id"); raw != "" { + p.DefinitionID = raw + } + + if raw := q.Get("sort"); raw != "" { + field := raw + desc := false + if strings.HasPrefix(field, "-") { + desc = true + field = field[1:] + } + switch field { + case "created_date", "updated_date", "name", "definition_id", "status", "version": + p.Sort = field + p.Desc = desc + default: + return p, domain.Invalid(fmt.Sprintf("cannot sort by %q", field)) + } + } + + if raw := q.Get("limit"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n < 0 { + return p, domain.Invalid("limit must be a non-negative integer") + } + if n > MaxLimit { + n = MaxLimit + } + p.Limit = n + } + + if raw := q.Get("offset"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n < 0 { + return p, domain.Invalid("offset must be a non-negative integer") + } + p.Offset = n + } + + return p, nil +} + +/* ── Agents ─────────────────────────────────────────────────────────────── */ + +// ListAgents returns a page of authored agent definitions. +func (s *DefinitionsService) ListAgents(ctx context.Context, ident authctx.Identity, p repo.DefinitionListParams) (*domain.Page, error) { + records, total, err := s.repo.ListAgents(ctx, ident, p) + if err != nil { + return nil, err + } + if records == nil { + records = []domain.Record{} + } + return &domain.Page{ + Records: records, + Total: total, + Limit: p.Limit, + Offset: p.Offset, + }, nil +} + +// GetAgent returns one agent definition by id within the caller's tenant and ownership scope. +func (s *DefinitionsService) GetAgent(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) { + if !isUUID(id) { + return nil, domain.NotFound("AgentDefinition", id) + } + rec, err := s.repo.GetAgent(ctx, ident, id) + if err != nil { + return nil, err + } + if rec == nil { + return nil, domain.NotFound("AgentDefinition", id) + } + return rec, nil +} + +// CreateAgent validates, parses and persists a new authored agent definition. +func (s *DefinitionsService) CreateAgent(ctx context.Context, ident authctx.Identity, body domain.Record) (domain.Record, error) { + mdRaw, ok := body["markdown"] + if !ok || mdRaw == nil { + return nil, domain.Validation("Paste or upload a Markdown definition.", nil) + } + markdown, isStr := mdRaw.(string) + if !isStr { + return nil, domain.Validation("markdown must be a string", nil) + } + + if err := definition.ValidateAgent(markdown); err != nil { + return nil, domain.Validation(err.Error(), nil) + } + + visibility := "personal" + if visRaw, ok := body["visibility"]; ok && visRaw != nil { + v, isStr := visRaw.(string) + if !isStr || (v != "personal" && v != "organization") { + return nil, domain.Validation("visibility must be one of: personal, organization", map[string]string{"visibility": "invalid"}) + } + visibility = v + } + + if visibility == "organization" { + role, known := domain.ParseRole(ident.Role) + if !known || role == domain.RoleTalent { + return nil, domain.Forbidden() + } + } + + agent, err := definition.ParseAgent(markdown, definition.Options{}) + if err != nil { + return nil, domain.Validation("That definition could not be parsed. "+err.Error(), nil) + } + + input := repo.AgentInsertInput{ + DefinitionID: agent.ID, + OrgID: ident.OrgID, + Visibility: visibility, + CreatedBy: &ident.UserID, + Markdown: markdown, + Status: agent.Status, + Version: agent.Version, + Name: agent.Name, + Description: agent.Description, + Pages: agent.Pages, + } + + if visibility == "personal" { + input.OwnerUserID = &ident.UserID + } + + return s.repo.InsertAgent(ctx, ident, input) +} + +// UpdateAgent validates and applies updates to an authored agent definition. +func (s *DefinitionsService) UpdateAgent(ctx context.Context, ident authctx.Identity, id string, patch domain.Record) (domain.Record, error) { + if !isUUID(id) { + return nil, domain.NotFound("AgentDefinition", id) + } + + existing, err := s.repo.GetAgent(ctx, ident, id) + if err != nil { + return nil, err + } + if existing == nil { + return nil, domain.NotFound("AgentDefinition", id) + } + + if existing["visibility"] == "organization" { + role, known := domain.ParseRole(ident.Role) + if !known || role == domain.RoleTalent { + return nil, domain.Forbidden() + } + } + + if visRaw, ok := patch["visibility"]; ok && visRaw != nil { + if v, isStr := visRaw.(string); isStr && v != existing["visibility"] { + return nil, domain.Validation("visibility cannot be modified after creation", map[string]string{"visibility": "immutable"}) + } + } + + var input repo.AgentUpdateInput + + if mdRaw, ok := patch["markdown"]; ok && mdRaw != nil { + markdown, isStr := mdRaw.(string) + if !isStr { + return nil, domain.Validation("markdown must be a string", nil) + } + if err := definition.ValidateAgent(markdown); err != nil { + return nil, domain.Validation(err.Error(), nil) + } + agent, err := definition.ParseAgent(markdown, definition.Options{}) + if err != nil { + return nil, domain.Validation("That definition could not be parsed. "+err.Error(), nil) + } + input.Markdown = &markdown + input.DefinitionID = &agent.ID + input.Name = &agent.Name + input.Description = &agent.Description + input.Status = &agent.Status + input.Version = &agent.Version + input.Pages = agent.Pages + } else if statusRaw, ok := patch["status"]; ok && statusRaw != nil { + status, isStr := statusRaw.(string) + if !isStr || (status != "draft" && status != "published" && status != "archived") { + return nil, domain.Validation("status must be one of: draft, published, archived", map[string]string{"status": "invalid"}) + } + input.Status = &status + } + + return s.repo.UpdateAgent(ctx, ident, id, input) +} + +// DeleteAgent removes an agent definition following idempotent delete semantics. +func (s *DefinitionsService) DeleteAgent(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) { + if !isUUID(id) { + return domain.Record{"id": id}, nil + } + + existing, err := s.repo.GetAgent(ctx, ident, id) + if err != nil { + return nil, err + } + if existing == nil { + return domain.Record{"id": id}, nil + } + + if existing["visibility"] == "organization" { + role, known := domain.ParseRole(ident.Role) + if !known || role == domain.RoleTalent { + return nil, domain.Forbidden() + } + } + + if _, err := s.repo.DeleteAgent(ctx, ident, id); err != nil { + return nil, err + } + return domain.Record{"id": id}, nil +} + +/* ── Skills ─────────────────────────────────────────────────────────────── */ + +// ListSkills returns a page of authored skill definitions. +func (s *DefinitionsService) ListSkills(ctx context.Context, ident authctx.Identity, p repo.DefinitionListParams) (*domain.Page, error) { + records, total, err := s.repo.ListSkills(ctx, ident, p) + if err != nil { + return nil, err + } + if records == nil { + records = []domain.Record{} + } + return &domain.Page{ + Records: records, + Total: total, + Limit: p.Limit, + Offset: p.Offset, + }, nil +} + +// GetSkill returns one skill definition by id within the caller's tenant and ownership scope. +func (s *DefinitionsService) GetSkill(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) { + if !isUUID(id) { + return nil, domain.NotFound("SkillDefinition", id) + } + rec, err := s.repo.GetSkill(ctx, ident, id) + if err != nil { + return nil, err + } + if rec == nil { + return nil, domain.NotFound("SkillDefinition", id) + } + return rec, nil +} + +// CreateSkill validates, parses and persists a new authored skill definition. +func (s *DefinitionsService) CreateSkill(ctx context.Context, ident authctx.Identity, body domain.Record) (domain.Record, error) { + mdRaw, ok := body["markdown"] + if !ok || mdRaw == nil { + return nil, domain.Validation("Paste or upload a Markdown definition.", nil) + } + markdown, isStr := mdRaw.(string) + if !isStr { + return nil, domain.Validation("markdown must be a string", nil) + } + + if err := definition.ValidateSkill(markdown); err != nil { + return nil, domain.Validation(err.Error(), nil) + } + + visibility := "personal" + if visRaw, ok := body["visibility"]; ok && visRaw != nil { + v, isStr := visRaw.(string) + if !isStr || (v != "personal" && v != "organization") { + return nil, domain.Validation("visibility must be one of: personal, organization", map[string]string{"visibility": "invalid"}) + } + visibility = v + } + + if visibility == "organization" { + role, known := domain.ParseRole(ident.Role) + if !known || role == domain.RoleTalent { + return nil, domain.Forbidden() + } + } + + skill, err := definition.ParseSkill(markdown, definition.Options{}) + if err != nil { + return nil, domain.Validation("That definition could not be parsed. "+err.Error(), nil) + } + + input := repo.SkillInsertInput{ + DefinitionID: skill.ID, + OrgID: ident.OrgID, + Visibility: visibility, + CreatedBy: &ident.UserID, + Markdown: markdown, + Status: skill.Status, + Name: skill.Name, + Description: skill.Description, + Pages: skill.Pages, + } + + if visibility == "personal" { + input.OwnerUserID = &ident.UserID + } + + return s.repo.InsertSkill(ctx, ident, input) +} + +// UpdateSkill validates and applies updates to an authored skill definition. +func (s *DefinitionsService) UpdateSkill(ctx context.Context, ident authctx.Identity, id string, patch domain.Record) (domain.Record, error) { + if !isUUID(id) { + return nil, domain.NotFound("SkillDefinition", id) + } + + existing, err := s.repo.GetSkill(ctx, ident, id) + if err != nil { + return nil, err + } + if existing == nil { + return nil, domain.NotFound("SkillDefinition", id) + } + + if existing["visibility"] == "organization" { + role, known := domain.ParseRole(ident.Role) + if !known || role == domain.RoleTalent { + return nil, domain.Forbidden() + } + } + + if visRaw, ok := patch["visibility"]; ok && visRaw != nil { + if v, isStr := visRaw.(string); isStr && v != existing["visibility"] { + return nil, domain.Validation("visibility cannot be modified after creation", map[string]string{"visibility": "immutable"}) + } + } + + var input repo.SkillUpdateInput + + if mdRaw, ok := patch["markdown"]; ok && mdRaw != nil { + markdown, isStr := mdRaw.(string) + if !isStr { + return nil, domain.Validation("markdown must be a string", nil) + } + if err := definition.ValidateSkill(markdown); err != nil { + return nil, domain.Validation(err.Error(), nil) + } + skill, err := definition.ParseSkill(markdown, definition.Options{}) + if err != nil { + return nil, domain.Validation("That definition could not be parsed. "+err.Error(), nil) + } + input.Markdown = &markdown + input.DefinitionID = &skill.ID + input.Name = &skill.Name + input.Description = &skill.Description + input.Status = &skill.Status + input.Pages = skill.Pages + } else if statusRaw, ok := patch["status"]; ok && statusRaw != nil { + status, isStr := statusRaw.(string) + if !isStr || (status != "active" && status != "inactive") { + return nil, domain.Validation("status must be one of: active, inactive", map[string]string{"status": "invalid"}) + } + input.Status = &status + } + + return s.repo.UpdateSkill(ctx, ident, id, input) +} + +// DeleteSkill removes a skill definition following idempotent delete semantics. +func (s *DefinitionsService) DeleteSkill(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) { + if !isUUID(id) { + return domain.Record{"id": id}, nil + } + + existing, err := s.repo.GetSkill(ctx, ident, id) + if err != nil { + return nil, err + } + if existing == nil { + return domain.Record{"id": id}, nil + } + + if existing["visibility"] == "organization" { + role, known := domain.ParseRole(ident.Role) + if !known || role == domain.RoleTalent { + return nil, domain.Forbidden() + } + } + + if _, err := s.repo.DeleteSkill(ctx, ident, id); err != nil { + return nil, err + } + return domain.Record{"id": id}, nil +} diff --git a/go-api/internal/service/service.go b/go-api/internal/service/service.go new file mode 100644 index 0000000..b83945c --- /dev/null +++ b/go-api/internal/service/service.go @@ -0,0 +1,335 @@ +// Package service sits between the HTTP layer and the repositories. +// +// It owns request validation, organization scoping, and the three behaviours +// the contract is most specific about: what a missing record does on read +// (§5.1), what a missing record does on delete (§12.7), and what a PATCH is +// allowed to touch (§3.2). +// +// No business rule lives here that the frontend does not already impose. The +// scoring, funnel and matching logic all stay client-side in Phase 2C. +package service + +import ( + "context" + "fmt" + "net/url" + "sort" + "strconv" + "strings" + + "github.com/krow/krow-backend/go-api/internal/authctx" + "github.com/krow/krow-backend/go-api/internal/domain" + "github.com/krow/krow-backend/go-api/internal/repo" +) + +// MaxLimit caps how much a single request can ask for. Nothing in the frontend +// asks for more than 500; this exists so a hand-written query cannot ask for +// everything. See api-contract.md §8. +const MaxLimit = 1000 + +// Service serves one resource. +type Service struct { + res *domain.Resource + db repo.Querier +} + +// New builds a service for a resource. +func New(res *domain.Resource, db repo.Querier) *Service { + return &Service{res: res, db: db} +} + +// Resource is the descriptor this service serves. +func (s *Service) Resource() *domain.Resource { return s.res } + +func (s *Service) repo() *repo.Repo { return repo.New(s.res, s.db) } + +/* ── Query parsing ──────────────────────────────────────────────────────── */ + +// Reserved query parameters. Every other parameter is a field filter. +// No column in any resource collides with these. See api-contract.md §1. +var reserved = map[string]bool{"sort": true, "limit": true, "offset": true} + +// ParseList turns a query string into validated list parameters, applying this +// resource's own defaults. The defaults are not generic: each one is the +// literal argument at the frontend call site (api-contract.md §8.1). +func (s *Service) ParseList(q url.Values) (domain.ListParams, error) { + p := domain.ListParams{Limit: s.res.DefaultLimit} + + sortSpec := s.res.DefaultSort + if raw, ok := q["sort"]; ok && len(raw) > 0 { + sortSpec = raw[0] // an explicitly empty ?sort= means "no ordering" + } + if sortSpec != "" { + field := sortSpec + if strings.HasPrefix(field, "-") { + p.Desc, field = true, field[1:] + } + if !s.res.Sortable(field) { + return p, domain.Invalid(fmt.Sprintf("cannot sort by %q on %s", field, s.res.Name)) + } + p.Sort = field + } + + if raw := q.Get("limit"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n < 0 { + return p, domain.Invalid("limit must be a non-negative integer") + } + if n > MaxLimit { + n = MaxLimit + } + p.Limit = n + } + if raw := q.Get("offset"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n < 0 { + return p, domain.Invalid("offset must be a non-negative integer") + } + p.Offset = n + } + + // Deterministic filter order keeps generated SQL stable and cacheable. + names := make([]string, 0, len(q)) + for name := range q { + if !reserved[name] { + names = append(names, name) + } + } + sort.Strings(names) + + for _, name := range names { + col, ok := s.res.Column(name) + if !ok { + return p, domain.Invalid(fmt.Sprintf("unknown filter field %q on %s", name, s.res.Name)) + } + if !s.res.Filterable(name) { + return p, domain.Invalid(fmt.Sprintf( + "%s is not filterable: array and JSON columns cannot be compared for equality", name)) + } + values := q[name] + if len(values) == 0 { + continue + } + p.Filters = append(p.Filters, domain.Filter{Column: col, Values: values}) + } + return p, nil +} + +/* ── Reads ──────────────────────────────────────────────────────────────── */ + +// List returns a page. An empty result is a page with no records, never an error. +func (s *Service) List(ctx context.Context, ident authctx.Identity, p domain.ListParams) (*domain.Page, error) { + page, err := s.repo().List(ctx, ident, p) + if err != nil { + return nil, err + } + if page.Records == nil { + page.Records = []domain.Record{} + } + return page, nil +} + +// Get returns one record, or a not_found error carrying store.js's message. +func (s *Service) Get(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) { + if !isUUID(id) { + // store.js throws " not found" for any id it cannot find, + // and a malformed id is simply an id it cannot find. + return nil, domain.NotFound(s.res.Name, id) + } + rec, err := s.repo().Get(ctx, ident, id) + if err != nil { + return nil, err + } + if rec == nil { + return nil, domain.NotFound(s.res.Name, id) + } + return rec, nil +} + +/* ── Writes ─────────────────────────────────────────────────────────────── */ + +// Create validates and inserts, returning the complete stored record. +func (s *Service) Create(ctx context.Context, ident authctx.Identity, body domain.Record) (domain.Record, error) { + clean, err := s.validate(body, true) + if err != nil { + return nil, err + } + return s.repo().Insert(ctx, ident, clean) +} + +// Update shallow-merges the supplied fields. Absent keys are left untouched. +func (s *Service) Update(ctx context.Context, ident authctx.Identity, id string, patch domain.Record) (domain.Record, error) { + if !isUUID(id) { + return nil, domain.NotFound(s.res.Name, id) + } + clean, err := s.validate(patch, false) + if err != nil { + return nil, err + } + rec, err := s.repo().Update(ctx, ident, id, clean) + if err != nil { + return nil, err + } + if rec == nil { + return nil, domain.NotFound(s.res.Name, id) + } + return rec, nil +} + +// Delete removes a record and always reports success. +// +// store.js filters its array and returns { id } whether or not anything +// matched, and both live callers delete inside loops without checking. A 404 +// here would surface an error toast where none appears today. +// See api-contract.md §12.7. +func (s *Service) Delete(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) { + if !isUUID(id) { + return domain.Record{"id": id}, nil + } + if _, err := s.repo().Delete(ctx, ident, id); err != nil { + return nil, err + } + return domain.Record{"id": id}, nil +} + +/* ── Validation ─────────────────────────────────────────────────────────── */ + +// validate checks a request body against the resource's columns and returns a +// copy with server-owned fields removed. +// +// Unknown fields are rejected rather than ignored. Silently dropping them is +// exactly how `interview_id`, `training_outline` and `score_breakdown` would +// have been lost: the frontend would have written them, the API would have +// accepted the request, and the data would never have arrived. +func (s *Service) validate(in domain.Record, isCreate bool) (domain.Record, error) { + details := map[string]string{} + out := make(domain.Record, len(in)) + + for name, value := range in { + col, ok := s.res.Column(name) + if !ok { + details[name] = "unknown field" + continue + } + if col.ReadOnly { + continue // server-owned: ignored, not rejected (api-contract.md §3.1) + } + if value == nil { + if col.NotNull { + details[name] = "must not be null" + continue + } + out[name] = nil + continue + } + if col.Kind == domain.KindEnum { + str, isStr := value.(string) + if !isStr || !contains(col.Enum, str) { + details[name] = fmt.Sprintf("must be one of: %s", strings.Join(col.Enum, ", ")) + continue + } + } + out[name] = value + } + + if isCreate { + for _, col := range s.res.Columns { + if !col.Required { + continue + } + if s.serverSupplies(col.Name) { + // The repository fills this from the session, so demanding it + // from the caller would reject a request the server is about to + // complete correctly. evidence.worker_email is the live case. + continue + } + v, ok := out[col.Name] + if !ok { + details[col.Name] = "required" + continue + } + if str, isStr := v.(string); isStr && strings.TrimSpace(str) == "" { + details[col.Name] = "must not be blank" + } + } + } + + if len(details) > 0 { + return nil, domain.Validation( + fmt.Sprintf("%s payload is not valid", s.res.Name), details) + } + return out, nil +} + +// serverSupplies reports whether a column is filled in from the authenticated +// session rather than from the request body. +func (s *Service) serverSupplies(name string) bool { + if s.res.Policy == nil { + return false + } + for _, d := range s.res.Policy.Derived { + if d.Column == name { + return true + } + } + return false +} + +func contains(set []string, v string) bool { + for _, s := range set { + if s == v { + return true + } + } + return false +} + +// isUUID reports whether a string is shaped like a canonical UUID. Cheap enough +// to run per request and it keeps a malformed id out of the SQL entirely. +func isUUID(s string) bool { + if len(s) != 36 { + return false + } + for i, c := range s { + switch i { + case 8, 13, 18, 23: + if c != '-' { + return false + } + default: + isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') + if !isHex { + return false + } + } + } + return true +} + +/* ── Registry ───────────────────────────────────────────────────────────── */ + +// Registry holds one service per resource that has an endpoint. +type Registry struct { + byPath map[string]*Service + order []*Service +} + +// NewRegistry builds services for every resource in domain.AllResources. +func NewRegistry(db repo.Querier) *Registry { + reg := &Registry{byPath: make(map[string]*Service, len(domain.AllResources))} + for _, res := range domain.AllResources { + svc := New(res, db) + reg.byPath[res.Path] = svc + reg.order = append(reg.order, svc) + } + return reg +} + +// Get returns the service for a URL path segment. +func (r *Registry) Get(path string) (*Service, bool) { + s, ok := r.byPath[path] + return s, ok +} + +// All returns every service, in declaration order. +func (r *Registry) All() []*Service { return r.order } diff --git a/go-api/internal/service/service_test.go b/go-api/internal/service/service_test.go new file mode 100644 index 0000000..c2c816e --- /dev/null +++ b/go-api/internal/service/service_test.go @@ -0,0 +1,217 @@ +package service + +import ( + "net/url" + "testing" + + "github.com/krow/krow-backend/go-api/internal/domain" +) + +// These exercise query parsing and validation without a database, so the +// contract's defaults are pinned even when PostgreSQL is not available. + +func resource(t *testing.T, path string) *domain.Resource { + t.Helper() + res, ok := domain.ResourceByPath[path] + if !ok { + t.Fatalf("no resource for path %q", path) + } + return res +} + +func parse(t *testing.T, path, query string) (domain.ListParams, error) { + t.Helper() + values, err := url.ParseQuery(query) + if err != nil { + t.Fatalf("bad test query %q: %v", query, err) + } + return New(resource(t, path), nil).ParseList(values) +} + +// Each default is the literal argument at the frontend call site +// (api-contract.md §8.1), not a generic value. +func TestParseListDefaults(t *testing.T) { + for _, tc := range []struct { + path string + limit int + sort string + desc bool + }{ + {"job-postings", 100, "created_date", true}, + {"job-applications", 200, "ai_score", true}, + {"worker-profiles", 500, "krow_score", true}, + {"shift-records", 500, "created_date", true}, + {"user-activity", 500, "created_date", true}, + {"courses", 200, "created_date", true}, + } { + p, err := parse(t, tc.path, "") + if err != nil { + t.Fatalf("%s: %v", tc.path, err) + } + if p.Limit != tc.limit { + t.Errorf("%s limit = %d, want %d", tc.path, p.Limit, tc.limit) + } + if p.Sort != tc.sort || p.Desc != tc.desc { + t.Errorf("%s sort = %q desc=%v, want %q desc=%v", tc.path, p.Sort, p.Desc, tc.sort, tc.desc) + } + } +} + +// An explicitly empty ?sort= means no ordering, matching `if (!sort) return records`. +func TestParseListEmptySortMeansUnordered(t *testing.T) { + p, err := parse(t, "job-postings", "sort=") + if err != nil { + t.Fatal(err) + } + if p.Sort != "" { + t.Errorf("sort = %q, want empty", p.Sort) + } +} + +func TestParseListLimitClampAndRejection(t *testing.T) { + p, err := parse(t, "job-postings", "limit=99999") + if err != nil { + t.Fatal(err) + } + if p.Limit != MaxLimit { + t.Errorf("limit = %d, want it clamped to %d", p.Limit, MaxLimit) + } + // A zero limit is legitimate: store.js's slice(0, 0) returns nothing. + if p, err := parse(t, "job-postings", "limit=0"); err != nil || p.Limit != 0 { + t.Errorf("limit=0 -> %d, %v", p.Limit, err) + } + for _, bad := range []string{"limit=-1", "limit=abc", "offset=-3", "offset=x"} { + if _, err := parse(t, "job-postings", bad); err == nil { + t.Errorf("%s was accepted", bad) + } + } +} + +func TestParseListRejectsUnknownSortAndFilter(t *testing.T) { + if _, err := parse(t, "job-postings", "sort=-nope"); err == nil { + t.Error("unknown sort field was accepted") + } + if _, err := parse(t, "job-postings", "nope=1"); err == nil { + t.Error("unknown filter field was accepted") + } + // Arrays and JSON are not comparable for equality, so they are not filterable. + if _, err := parse(t, "job-postings", "responsibilities=x"); err == nil { + t.Error("an array column was accepted as a filter") + } + if _, err := parse(t, "job-postings", "vetting_criteria=x"); err == nil { + t.Error("a jsonb column was accepted as a filter") + } +} + +// A repeated parameter is membership, matching `Array.isArray(want)`. +func TestParseListRepeatedParameterIsMembership(t *testing.T) { + p, err := parse(t, "job-applications", "status=hired&status=interview") + if err != nil { + t.Fatal(err) + } + if len(p.Filters) != 1 { + t.Fatalf("filters = %d, want 1", len(p.Filters)) + } + if len(p.Filters[0].Values) != 2 { + t.Errorf("values = %v, want two", p.Filters[0].Values) + } +} + +// sort, limit and offset are reserved; no column collides with them. +func TestReservedParametersAreNotFilters(t *testing.T) { + p, err := parse(t, "job-applications", "sort=-ai_score&limit=5&offset=2&status=hired") + if err != nil { + t.Fatal(err) + } + if len(p.Filters) != 1 || p.Filters[0].Column.Name != "status" { + t.Errorf("filters = %#v, want only status", p.Filters) + } + if p.Limit != 5 || p.Offset != 2 { + t.Errorf("limit/offset = %d/%d, want 5/2", p.Limit, p.Offset) + } + for _, r := range []string{"sort", "limit", "offset"} { + if _, isColumn := resource(t, "job-applications").Column(r); isColumn { + t.Errorf("a column named %q collides with a reserved parameter", r) + } + } +} + +func TestValidateRequiredAndUnknownAndEnum(t *testing.T) { + svc := New(resource(t, "job-postings"), nil) + + if _, err := svc.validate(domain.Record{}, true); err == nil { + t.Error("a create with no title was accepted") + } + if _, err := svc.validate(domain.Record{"title": " "}, true); err == nil { + t.Error("a blank title was accepted") + } + if _, err := svc.validate(domain.Record{"title": "X", "bogus": 1}, true); err == nil { + t.Error("an unknown field was accepted") + } + if _, err := svc.validate(domain.Record{"title": "X", "status": "archived"}, true); err == nil { + t.Error("an invalid enum value was accepted") + } + if _, err := svc.validate(domain.Record{"title": "X", "status": "active"}, true); err != nil { + t.Errorf("a valid payload was rejected: %v", err) + } + + // Server-owned fields are stripped, not rejected. + out, err := svc.validate(domain.Record{"title": "X", "id": "abc", "org_id": "def"}, true) + if err != nil { + t.Fatalf("server-owned fields caused a rejection: %v", err) + } + if _, present := out["id"]; present { + t.Error("id survived validation") + } + if _, present := out["org_id"]; present { + t.Error("org_id survived validation") + } + + // An update needs no required fields — it is a partial by definition. + if _, err := svc.validate(domain.Record{"location": "Here"}, false); err != nil { + t.Errorf("a partial update was rejected: %v", err) + } +} + +func TestIsUUID(t *testing.T) { + valid := []string{ + "00000000-0000-0000-0000-000000000000", + "9A88DEBC-76E5-572C-A7E7-6EB5F43A6705", + } + for _, v := range valid { + if !isUUID(v) { + t.Errorf("%q rejected", v) + } + } + invalid := []string{"", "not-a-uuid", "00000000000000000000000000000000", + "00000000-0000-0000-0000-00000000000g", "00000000-0000-0000-0000-0000000000000"} + for _, v := range invalid { + if isUUID(v) { + t.Errorf("%q accepted", v) + } + } +} + +// Every resource must declare a sort column that actually exists, and a limit +// in range — a typo in the generated metadata would otherwise only surface as a +// 400 at runtime. +func TestEveryResourceIsCoherent(t *testing.T) { + for _, res := range domain.AllResources { + field := res.DefaultSort + if len(field) > 0 && field[0] == '-' { + field = field[1:] + } + if !res.Sortable(field) { + t.Errorf("%s: default sort %q is not a column", res.Name, res.DefaultSort) + } + if res.DefaultLimit < 1 || res.DefaultLimit > MaxLimit { + t.Errorf("%s: default limit %d is out of range", res.Name, res.DefaultLimit) + } + if _, ok := res.Column("id"); !ok { + t.Errorf("%s: no id column, so the sort tiebreaker cannot apply", res.Name) + } + if _, ok := res.Column("org_id"); !ok { + t.Errorf("%s: no org_id column, so it cannot be scoped", res.Name) + } + } +} diff --git a/go-api/internal/testutil/db.go b/go-api/internal/testutil/db.go new file mode 100644 index 0000000..0978bbd --- /dev/null +++ b/go-api/internal/testutil/db.go @@ -0,0 +1,309 @@ +// Package testutil builds a disposable, fully migrated and seeded database for +// tests. +// +// The test database is created from scratch on every run and is named +// distinctly from any real one. Nothing here ever connects to, reads or drops +// the development database. +package testutil + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/krow/krow-backend/go-api/internal/seeder" +) + +// testDBPrefix names the throwaway databases. The prefix is deliberate: a name +// this specific cannot be mistaken for, or collide with, "Krow-force". +// +// The pid is appended because `go test ./...` runs each package in its own +// process, concurrently — a single shared name means one package drops the +// database another is still using. +const testDBPrefix = "krow_backend_autotest" + +// TestDBName is this process's throwaway database. +var TestDBName = fmt.Sprintf("%s_%d", testDBPrefix, os.Getpid()) + +// Harness is a ready database plus what was seeded into it. +type Harness struct { + Pool *pgxpool.Pool + OrgID string + Seeded *seeder.Result + Now time.Time +} + +func env(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +func dsn(database string) string { + return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", + env("DATABASE_USER", "postgres"), env("DATABASE_PASSWORD", ""), + env("DATABASE_HOST", "127.0.0.1"), env("DATABASE_PORT", "5432"), database) +} + +// repoRoot walks up from the test's working directory to the repository root, +// found by the migrations directory sitting beside go-api. +func repoRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + for i := 0; i < 6; i++ { + if _, err := os.Stat(filepath.Join(dir, "migrations")); err == nil { + return dir + } + dir = filepath.Dir(dir) + } + t.Fatalf("could not locate the repository root from the test working directory") + return "" +} + +// New builds a migrated, seeded database, or skips the test when PostgreSQL is +// not reachable — so `go test ./...` still runs on a machine without a server. +func New(t *testing.T) *Harness { + t.Helper() + ctx := context.Background() + + admin, err := pgxpool.New(ctx, dsn("postgres")) + if err != nil { + t.Skipf("PostgreSQL unavailable, skipping database tests: %v", err) + } + if err := admin.Ping(ctx); err != nil { + admin.Close() + t.Skipf("PostgreSQL unavailable, skipping database tests: %v", err) + } + + // Terminate stragglers so DROP cannot block on a leaked connection. + _, _ = admin.Exec(ctx, + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, TestDBName) + if _, err := admin.Exec(ctx, `DROP DATABASE IF EXISTS `+quoteIdent(TestDBName)); err != nil { + admin.Close() + t.Fatalf("drop test database: %v", err) + } + if _, err := admin.Exec(ctx, `CREATE DATABASE `+quoteIdent(TestDBName)); err != nil { + admin.Close() + t.Fatalf("create test database: %v", err) + } + admin.Close() + + pool, err := pgxpool.New(ctx, dsn(TestDBName)) + if err != nil { + t.Fatalf("connect to test database: %v", err) + } + + root := repoRoot(t) + applyMigrations(t, ctx, pool, filepath.Join(root, "migrations")) + + fixture, err := seeder.Load(filepath.Join(root, "seed", "fixtures", "seed.json")) + if err != nil { + t.Fatalf("load fixture: %v", err) + } + now := time.Now() + result, err := seeder.New(pool, fixture, now).Run(ctx) + if err != nil { + t.Fatalf("seed: %v", err) + } + + t.Cleanup(func() { + pool.Close() + dropTestDatabase() + }) + return &Harness{Pool: pool, OrgID: result.OrgID, Seeded: result, Now: now} +} + +// dropTestDatabase removes this process's throwaway database. Best effort: a +// leftover is harmless because the next run drops it before creating it. +func dropTestDatabase() { + ctx := context.Background() + admin, err := pgxpool.New(ctx, dsn("postgres")) + if err != nil { + return + } + defer admin.Close() + _, _ = admin.Exec(ctx, + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, TestDBName) + _, _ = admin.Exec(ctx, `DROP DATABASE IF EXISTS `+quoteIdent(TestDBName)) +} + +// applyMigrations runs every *.up.sql in filename order. This is the same SQL +// golang-migrate applies; running it directly keeps the tests independent of +// the CLI being installed. +func applyMigrations(t *testing.T, ctx context.Context, pool *pgxpool.Pool, dir string) { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read migrations: %v", err) + } + var files []string + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".up.sql") { + files = append(files, e.Name()) + } + } + sort.Strings(files) + if len(files) == 0 { + t.Fatal("no migrations found") + } + for _, name := range files { + sqlBytes, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + if _, err := pool.Exec(ctx, string(sqlBytes)); err != nil { + t.Fatalf("apply %s: %v", name, err) + } + } +} + +// quoteIdent renders an identifier safely. The only value passed here is the +// package constant above, but building DDL by concatenation without quoting is +// a habit worth not having. +func quoteIdent(s string) string { + return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` +} + +// Fixture reloads the raw fixture so tests can assert the database against the +// frontend's own data rather than against numbers typed into a test. +func Fixture(t *testing.T) *seeder.Fixture { + t.Helper() + f, err := seeder.Load(filepath.Join(repoRoot(t), "seed", "fixtures", "seed.json")) + if err != nil { + t.Fatalf("load fixture: %v", err) + } + return f +} + +/* ── Migration sandboxes ──────────────────────────────────────────────────── + * + * The helpers below exist for tests that drive the migration FILES themselves + * — applying them, rolling them back, re-applying them — rather than using the + * migrated database New() hands out. + * + * They need a database of their own for two reasons. New()'s database is + * dropped by its own t.Cleanup, so sharing it across a test that rolls the + * schema back would leave the next test's fixtures on the floor; and a down + * migration must run against a database whose contents the test controls, + * because 000003's down migration deliberately fails on seeded data. + * + * Like New(), nothing here can reach a real database: every name is built from + * testDBPrefix, which cannot be confused with "Krow-force". + */ + +// Sandbox creates an empty throwaway database and returns a pool on it. +// +// Nothing is migrated and nothing is seeded — that is the point. The label +// distinguishes concurrent sandboxes within one package; the pid keeps +// packages, which `go test ./...` runs in parallel processes, from colliding. +func Sandbox(t *testing.T, label string) *pgxpool.Pool { + t.Helper() + ctx := context.Background() + name := fmt.Sprintf("%s_%s_%d", testDBPrefix, label, os.Getpid()) + + admin, err := pgxpool.New(ctx, dsn("postgres")) + if err != nil { + t.Skipf("PostgreSQL unavailable, skipping database tests: %v", err) + } + if err := admin.Ping(ctx); err != nil { + admin.Close() + t.Skipf("PostgreSQL unavailable, skipping database tests: %v", err) + } + dropDatabase(ctx, admin, name) + if _, err := admin.Exec(ctx, `CREATE DATABASE `+quoteIdent(name)); err != nil { + admin.Close() + t.Fatalf("create sandbox database %s: %v", name, err) + } + admin.Close() + + pool, err := pgxpool.New(ctx, dsn(name)) + if err != nil { + t.Fatalf("connect to sandbox database: %v", err) + } + t.Cleanup(func() { + pool.Close() + cleanup, err := pgxpool.New(context.Background(), dsn("postgres")) + if err != nil { + return + } + defer cleanup.Close() + dropDatabase(context.Background(), cleanup, name) + }) + return pool +} + +// dropDatabase terminates stragglers, then drops. Best effort on the drop +// itself: a leftover is harmless because the next run drops it before creating. +func dropDatabase(ctx context.Context, admin *pgxpool.Pool, name string) { + _, _ = admin.Exec(ctx, + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, name) + _, _ = admin.Exec(ctx, `DROP DATABASE IF EXISTS `+quoteIdent(name)) +} + +// RepoRoot is the repository root, located from the test's working directory. +func RepoRoot(t *testing.T) string { + t.Helper() + return repoRoot(t) +} + +// MigrationsDir is the directory holding the migration files. +func MigrationsDir(t *testing.T) string { + t.Helper() + return filepath.Join(repoRoot(t), "migrations") +} + +// MigrationFiles lists the migration files with the given suffix — ".up.sql" +// or ".down.sql" — in filename order. Callers wanting to roll back should +// reverse the result. +func MigrationFiles(t *testing.T, suffix string) []string { + t.Helper() + entries, err := os.ReadDir(MigrationsDir(t)) + if err != nil { + t.Fatalf("read migrations: %v", err) + } + var files []string + for _, e := range entries { + if strings.HasSuffix(e.Name(), suffix) { + files = append(files, e.Name()) + } + } + sort.Strings(files) + if len(files) == 0 { + t.Fatalf("no %s migrations found", suffix) + } + return files +} + +// ApplyMigration runs one migration file and returns its error rather than +// failing the test, so a test can assert that a rollback succeeds — or, for +// 000003's down migration, that it does not. +func ApplyMigration(ctx context.Context, t *testing.T, pool *pgxpool.Pool, name string) error { + t.Helper() + sqlBytes, err := os.ReadFile(filepath.Join(MigrationsDir(t), name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + _, err = pool.Exec(ctx, string(sqlBytes)) + return err +} + +// ApplyAllMigrations applies every *.up.sql in order, failing the test on the +// first that does not apply. This is the same SQL golang-migrate would run. +func ApplyAllMigrations(ctx context.Context, t *testing.T, pool *pgxpool.Pool) { + t.Helper() + applyMigrations(t, ctx, pool, MigrationsDir(t)) +} diff --git a/infrastructure/README.md b/infrastructure/README.md new file mode 100644 index 0000000..155bfff --- /dev/null +++ b/infrastructure/README.md @@ -0,0 +1,21 @@ +# infrastructure + +Deployment and local-environment definitions. + +**Empty in Phase 1, on purpose.** The Phase 1 scope is the Go module, the +database connection and the initial migration, run against a PostgreSQL 18.6 +instance that already exists on the developer's machine. Nothing here is needed +to get `make migrate-up && make run` working. + +What lands here in later phases, once each is actually approved: + +| File | Phase | Contents | +| --- | --- | --- | +| `docker-compose.dev.yml` | 2 | PostgreSQL + pgvector, so the dev database stops being a machine-local install | +| `docker-compose.dev.yml` (extended) | later | Redis, NATS, MinIO — each only when the phase that needs it starts | +| `Dockerfile.api` | later | Multi-stage build for `go-api` | +| `Dockerfile.owliver` | later | The Python service | +| `otel-collector.yaml` | later | OpenTelemetry collector config | + +Adding any of these before its phase would be speculative, so the directory +holds only this note for now. diff --git a/migrations/000001_initial_schema.down.sql b/migrations/000001_initial_schema.down.sql new file mode 100644 index 0000000..b5b6c02 --- /dev/null +++ b/migrations/000001_initial_schema.down.sql @@ -0,0 +1,59 @@ +-- ============================================================================ +-- Krow — initial schema, reversed. +-- +-- Drops exactly what 000001_initial_schema.up.sql created, in reverse +-- dependency order, and nothing else. Every DROP is qualified to `public` and +-- named explicitly — there is no CASCADE on a schema, no DROP SCHEMA, and no +-- DROP DATABASE anywhere in this file. +-- ============================================================================ + +SET search_path = public; + +-- Tables, children before parents. Indexes and table-level constraints are +-- dropped implicitly with their table. +DROP TABLE IF EXISTS public.user_activity; +DROP TABLE IF EXISTS public.evidence; +DROP TABLE IF EXISTS public.shift_records; +DROP TABLE IF EXISTS public.assignments; +DROP TABLE IF EXISTS public.staff; +DROP TABLE IF EXISTS public.ai_interviews; +DROP TABLE IF EXISTS public.job_applications; +DROP TABLE IF EXISTS public.worker_profiles; +DROP TABLE IF EXISTS public.job_postings; +DROP TABLE IF EXISTS public.learning_paths; +DROP TABLE IF EXISTS public.courses; +DROP TABLE IF EXISTS public.badges; +DROP TABLE IF EXISTS public.certifications; +DROP TABLE IF EXISTS public.role_categories; +DROP TABLE IF EXISTS public.user_preferences; +DROP TABLE IF EXISTS public.users; +DROP TABLE IF EXISTS public.organizations; + +-- Enums, after every table that referenced them is gone. +DROP TYPE IF EXISTS public.badge_verification; +DROP TYPE IF EXISTS public.badge_level; +DROP TYPE IF EXISTS public.evidence_verdict; +DROP TYPE IF EXISTS public.challenge_type; +DROP TYPE IF EXISTS public.skill_level; +DROP TYPE IF EXISTS public.course_status; +DROP TYPE IF EXISTS public.shift_status; +DROP TYPE IF EXISTS public.assignment_status; +DROP TYPE IF EXISTS public.profile_tier; +DROP TYPE IF EXISTS public.staff_status; +DROP TYPE IF EXISTS public.interview_verdict; +DROP TYPE IF EXISTS public.application_status; +DROP TYPE IF EXISTS public.english_level; +DROP TYPE IF EXISTS public.posting_priority; +DROP TYPE IF EXISTS public.posting_status; + +-- The citext extension is deliberately NOT dropped. +-- +-- CREATE EXTENSION IF NOT EXISTS is a no-op when the extension already exists, +-- so the up migration cannot tell whether it created citext or inherited it. +-- Dropping it on the way down would therefore risk removing an object this +-- migration never owned, and would break any other schema that has since +-- adopted the type. Leaving it is harmless: it holds no data, and re-applying +-- the up migration is unaffected. +-- +-- To remove it by hand on a database where nothing else uses it: +-- DROP EXTENSION IF EXISTS citext; diff --git a/migrations/000001_initial_schema.up.sql b/migrations/000001_initial_schema.up.sql new file mode 100644 index 0000000..039cb32 --- /dev/null +++ b/migrations/000001_initial_schema.up.sql @@ -0,0 +1,648 @@ +-- ============================================================================ +-- Krow — initial schema +-- +-- Source of truth: the Krow Backend Blueprint §05, reconciled against the +-- frontend repository (krow-demo) on 2026-08-21. Every table here corresponds +-- to an entity the frontend actually reads or writes through +-- `src/api/base44Client.js` (ENTITY_NAMES, lines 16-30), except `organizations` +-- and `user_preferences` — see the notes below. +-- +-- Deliberately NOT in this migration (Phase 2+): +-- sessions, definitions, definition_versions, conversations, +-- conversation_messages, conversation_feedback, file_assets, +-- documents, document_chunks, the `vector` extension. +-- +-- Target schema: public. No system schema is read or written. +-- ============================================================================ + +-- Atomicity comes from golang-migrate: the postgres driver sends this file as a +-- single simple query, which Postgres executes inside one implicit transaction. +-- Any failure below rolls the whole migration back. +SET search_path = public; + +-- citext gives case-insensitive email equality. Email is a real join key in +-- this domain, not a convenience: assignments, shift_records and evidence all +-- key the worker by email, and the frontend looks profiles up with +-- filter({ email }). The extension is created here but deliberately NOT +-- dropped by the down migration — see 000001_initial_schema.down.sql. +CREATE EXTENSION IF NOT EXISTS citext WITH SCHEMA public; + +-- ── Enums ─────────────────────────────────────────────────────────────────── +-- Every value below was enumerated from the frontend, not from the blueprint. +-- `src/components/ds/StatusBadge.jsx` STATUS_MAP is the authoritative vocabulary. + +CREATE TYPE posting_status AS ENUM ('draft', 'active', 'paused', 'closed'); +CREATE TYPE posting_priority AS ENUM ('urgent', 'high', 'normal'); +CREATE TYPE english_level AS ENUM ('basic', 'conversational', 'fluent', 'native'); + +-- 'assigned' is written by useAssignWorkers but is absent from STAGE_ORDER and +-- from STATUS_MAP, so those candidates vanish from funnel counts. That is +-- blueprint decision D3, still open. The value is included because the code +-- writes it; the funnel bug is a separate fix. +CREATE TYPE application_status AS ENUM ( + 'applied', 'ai_screened', 'shortlisted', 'interview', 'hired', 'rejected', 'assigned' +); + +-- aiEngine.js:436 — `overall >= 78 ? 'hire' : overall >= 55 ? 'maybe' : 'no'`. +-- The blueprint said 'yes'; the frontend says 'hire'. The frontend wins. +CREATE TYPE interview_verdict AS ENUM ('hire', 'maybe', 'no'); + +-- RetentionMetrics.jsx and hiringRecords.js:93 both test for 'inactive'. +-- The blueprint said 'ended'; no such value exists in the frontend. +CREATE TYPE staff_status AS ENUM ('onboarding', 'active', 'inactive'); +CREATE TYPE profile_tier AS ENUM ('Beginner', 'Cross-Trained', 'Skilled'); + +CREATE TYPE assignment_status AS ENUM ('active', 'completed', 'cancelled'); + +-- attendanceSeed.js writes exactly these four. The blueprint also listed +-- 'excused', which appears nowhere in the frontend, so it is omitted. +-- ALTER TYPE ... ADD VALUE can add it later without a table rewrite. +CREATE TYPE shift_status AS ENUM ('present', 'late', 'absent', 'no_show'); + +CREATE TYPE course_status AS ENUM ('active', 'inactive'); +CREATE TYPE skill_level AS ENUM ('beginner', 'intermediate', 'advanced', 'expert'); +CREATE TYPE challenge_type AS ENUM ('roleplay', 'video', 'photo_identify'); + +-- provingGround.js:6 declares enum ['verified','needs_work','failed']. +-- The blueprint listed only the first two. +CREATE TYPE evidence_verdict AS ENUM ('verified', 'needs_work', 'failed'); + +CREATE TYPE badge_level AS ENUM ('bronze', 'silver', 'gold', 'platinum'); +CREATE TYPE badge_verification AS ENUM ('pending', 'verified', 'expired'); + + +-- ── Tenancy ───────────────────────────────────────────────────────────────── +-- No frontend evidence. Carried from day one per blueprint decision D1: a +-- single-tenant deployment simply has one row, and retrofitting org_id across +-- 16 tables later is far more expensive than carrying it now. + +CREATE TABLE organizations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + slug citext NOT NULL UNIQUE, + created_date timestamptz NOT NULL DEFAULT now(), + updated_date timestamptz NOT NULL DEFAULT now(), + CONSTRAINT organizations_name_not_blank CHECK (length(btrim(name)) > 0) +); + + +-- ── Users ─────────────────────────────────────────────────────────────────── +-- `User` is one of the 15 frontend entities; auth.me / updateMe / preferences +-- are live in base44Client.js. This is schema only — no authentication is +-- implemented in Phase 1. `password_hash` is nullable and stays NULL until +-- auth is built. + +CREATE TABLE users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + legacy_id text UNIQUE, + org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + email citext NOT NULL, + full_name text NOT NULL DEFAULT '', + password_hash text, + role text NOT NULL DEFAULT 'admin', + account_type text NOT NULL DEFAULT 'employer', + status text NOT NULL DEFAULT 'active', + last_login_at timestamptz, + created_date timestamptz NOT NULL DEFAULT now(), + updated_date timestamptz NOT NULL DEFAULT now(), + CONSTRAINT users_org_email_key UNIQUE (org_id, email), + CONSTRAINT users_role_check CHECK (role IN ('admin', 'employer', 'talent')), + CONSTRAINT users_status_check CHECK (status IN ('active', 'suspended')), + CONSTRAINT users_email_not_blank CHECK (length(btrim(email::text)) > 0) +); + +-- Lifted out of the User JSON blob so preferences are queryable and small, +-- while auth.preferences() can still return a single merged object. +CREATE TABLE user_preferences ( + user_id uuid PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE, + owliver_default boolean NOT NULL DEFAULT true, + compact_density boolean NOT NULL DEFAULT false, + email_digest boolean NOT NULL DEFAULT true, + extra jsonb NOT NULL DEFAULT '{}'::jsonb, + updated_date timestamptz NOT NULL DEFAULT now() +); + + +-- ── Reference data ────────────────────────────────────────────────────────── + +CREATE TABLE role_categories ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + legacy_id text UNIQUE, + org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + name text NOT NULL, + created_date timestamptz NOT NULL DEFAULT now(), + updated_date timestamptz NOT NULL DEFAULT now(), + CONSTRAINT role_categories_org_name_key UNIQUE (org_id, name) +); + +CREATE TABLE certifications ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + legacy_id text UNIQUE, + org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + name text NOT NULL, + created_date timestamptz NOT NULL DEFAULT now(), + updated_date timestamptz NOT NULL DEFAULT now(), + CONSTRAINT certifications_org_name_key UNIQUE (org_id, name) +); + +-- Reference table only. The blueprint flags Badge as "inferred dead": the UI +-- reads every badge it displays from worker_profiles.earned_badges, and +-- useBadges is exported but never imported. The entity and its seed rows exist, +-- so the table is created; nothing reads it yet. +CREATE TABLE badges ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + legacy_id text UNIQUE, + org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + name text NOT NULL, + description text NOT NULL DEFAULT '', + image_url text NOT NULL DEFAULT '', + level badge_level NOT NULL DEFAULT 'bronze', + requirements text NOT NULL DEFAULT '', + expiration_months int, + verification_status badge_verification NOT NULL DEFAULT 'pending', + created_date timestamptz NOT NULL DEFAULT now(), + updated_date timestamptz NOT NULL DEFAULT now(), + CONSTRAINT badges_org_name_key UNIQUE (org_id, name), + CONSTRAINT badges_expiration_positive CHECK (expiration_months IS NULL OR expiration_months > 0) +); + + +-- ── Training ──────────────────────────────────────────────────────────────── + +CREATE TABLE courses ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + legacy_id text UNIQUE, + -- NULL org_id = platform-wide course library, shared by every organization. + org_id uuid REFERENCES organizations (id) ON DELETE CASCADE, + title text NOT NULL, + description text NOT NULL DEFAULT '', + category text NOT NULL DEFAULT '', + difficulty text NOT NULL DEFAULT 'beginner', + xp int NOT NULL DEFAULT 0, + estimated_minutes int NOT NULL DEFAULT 0, + badge_reward text, + proof_skill text NOT NULL DEFAULT '', + skill_id text, + target_level skill_level, + required_level skill_level, + completion_criteria text[] NOT NULL DEFAULT '{}', + verification_criteria text[] NOT NULL DEFAULT '{}', + challenge jsonb NOT NULL DEFAULT '{}'::jsonb, + unlock_requirements jsonb NOT NULL DEFAULT '{}'::jsonb, + quiz jsonb NOT NULL DEFAULT '[]'::jsonb, + pass_score int NOT NULL DEFAULT 70, + status course_status NOT NULL DEFAULT 'active', + created_date timestamptz NOT NULL DEFAULT now(), + updated_date timestamptz NOT NULL DEFAULT now(), + CONSTRAINT courses_title_not_blank CHECK (length(btrim(title)) > 0), + CONSTRAINT courses_xp_nonneg CHECK (xp >= 0), + CONSTRAINT courses_minutes_nonneg CHECK (estimated_minutes >= 0), + CONSTRAINT courses_pass_score_pct CHECK (pass_score BETWEEN 0 AND 100) +); + +CREATE INDEX courses_skill_level_idx ON courses (skill_id, target_level); +CREATE INDEX courses_org_status_idx ON courses (org_id, status); + +CREATE TABLE learning_paths ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + legacy_id text UNIQUE, + org_id uuid REFERENCES organizations (id) ON DELETE CASCADE, + name text NOT NULL, + target_role text NOT NULL DEFAULT '', + description text NOT NULL DEFAULT '', + difficulty text NOT NULL DEFAULT 'beginner', + -- [{ order, course_id, course_title }] — a json reference list, not an FK. + steps jsonb NOT NULL DEFAULT '[]'::jsonb, + created_date timestamptz NOT NULL DEFAULT now(), + updated_date timestamptz NOT NULL DEFAULT now(), + CONSTRAINT learning_paths_name_not_blank CHECK (length(btrim(name)) > 0), + CONSTRAINT learning_paths_steps_is_array CHECK (jsonb_typeof(steps) = 'array') +); + + +-- ── Hiring: postings ──────────────────────────────────────────────────────── + +CREATE TABLE job_postings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + legacy_id text UNIQUE, + org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + created_by uuid REFERENCES users (id) ON DELETE SET NULL, + + -- Free text today: the client a role is staffed for. Blueprint decision D2 + -- (promote to a `clients` table) is still open, so it stays text. + company text NOT NULL DEFAULT '', + title text NOT NULL, + -- Matched to role_categories.name BY NAME, exactly as the frontend does. + role_category text NOT NULL DEFAULT '', + description text NOT NULL DEFAULT '', + responsibilities text[] NOT NULL DEFAULT '{}', + qualifications text[] NOT NULL DEFAULT '{}', + nice_to_haves text[] NOT NULL DEFAULT '{}', + custom_requirements text NOT NULL DEFAULT '', + physical_requirements text NOT NULL DEFAULT '', + leadership_expectations text NOT NULL DEFAULT '', + attendance_expectations text NOT NULL DEFAULT '', + + min_experience_years int NOT NULL DEFAULT 0, + english_required english_level NOT NULL DEFAULT 'basic', + certifications_required text[] NOT NULL DEFAULT '{}', + -- [{ skill_id, level, weight }] + skill_requirements jsonb NOT NULL DEFAULT '[]'::jsonb, + pay_range_min int NOT NULL DEFAULT 0, + pay_range_max int NOT NULL DEFAULT 0, + location text NOT NULL DEFAULT '', + + status posting_status NOT NULL DEFAULT 'draft', + ai_generated boolean NOT NULL DEFAULT false, + headcount int NOT NULL DEFAULT 1, + start_date date, + duration_months numeric(4,1), + priority posting_priority NOT NULL DEFAULT 'normal', + vetting_criteria jsonb NOT NULL DEFAULT + '{"experience":25,"english":20,"reliability":20,"certifications":20,"availability":15}'::jsonb, + + created_date timestamptz NOT NULL DEFAULT now(), + updated_date timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT job_postings_title_not_blank CHECK (length(btrim(title)) > 0), + CONSTRAINT job_postings_experience_range CHECK (min_experience_years BETWEEN 0 AND 40), + CONSTRAINT job_postings_headcount_min CHECK (headcount >= 1), + CONSTRAINT job_postings_pay_nonneg CHECK (pay_range_min >= 0 AND pay_range_max >= 0), + -- A max of 0 means "unspecified", so it is exempt from the ordering rule. + CONSTRAINT job_postings_pay_ordered CHECK (pay_range_max = 0 OR pay_range_max >= pay_range_min), + CONSTRAINT job_postings_duration_positive CHECK (duration_months IS NULL OR duration_months > 0), + CONSTRAINT job_postings_skill_reqs_array CHECK (jsonb_typeof(skill_requirements) = 'array'), + CONSTRAINT job_postings_vetting_object CHECK (jsonb_typeof(vetting_criteria) = 'object') +); + +CREATE INDEX job_postings_org_created_idx ON job_postings (org_id, created_date DESC); +CREATE INDEX job_postings_org_active_idx ON job_postings (org_id, status) WHERE status = 'active'; +CREATE INDEX job_postings_org_category_idx ON job_postings (org_id, role_category); +CREATE INDEX job_postings_skill_reqs_gin ON job_postings USING gin (skill_requirements jsonb_path_ops); + + +-- ── Workforce: profiles ───────────────────────────────────────────────────── +-- Declared before job_applications because that table references it. + +CREATE TABLE worker_profiles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + legacy_id text UNIQUE, + org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + user_id uuid REFERENCES users (id) ON DELETE SET NULL, + full_name text NOT NULL, + email citext NOT NULL, + phone text NOT NULL DEFAULT '', + address text NOT NULL DEFAULT '', + selfie_url text NOT NULL DEFAULT '', + languages text[] NOT NULL DEFAULT '{}', + availability text[] NOT NULL DEFAULT '{}', + transportation text NOT NULL DEFAULT '', + certifications text[] NOT NULL DEFAULT '{}', + -- [{ company, role, years }] + experience jsonb NOT NULL DEFAULT '[]'::jsonb, + experience_years int NOT NULL DEFAULT 0, + current_position text NOT NULL DEFAULT '', + desired_position text NOT NULL DEFAULT '', + career_goals text NOT NULL DEFAULT '', + skills text[] NOT NULL DEFAULT '{}', + industries text[] NOT NULL DEFAULT '{}', + personality text NOT NULL DEFAULT '', + strengths text[] NOT NULL DEFAULT '{}', + weaknesses text[] NOT NULL DEFAULT '{}', + communication_style text NOT NULL DEFAULT '', + salary_expectations text NOT NULL DEFAULT '', + leadership_potential int NOT NULL DEFAULT 0, + ai_interview_score int NOT NULL DEFAULT 0, + krow_score int NOT NULL DEFAULT 0, + reliability_score int NOT NULL DEFAULT 0, + profile_completion int NOT NULL DEFAULT 0, + xp int NOT NULL DEFAULT 0, + completed_courses jsonb NOT NULL DEFAULT '[]'::jsonb, + earned_badges jsonb NOT NULL DEFAULT '[]'::jsonb, + capabilities jsonb NOT NULL DEFAULT '[]'::jsonb, + shifts_completed int NOT NULL DEFAULT 0, + attendance_score int NOT NULL DEFAULT 100, + performance_score int NOT NULL DEFAULT 0, + client_rating numeric(2,1) NOT NULL DEFAULT 0, + supervisor_rating numeric(2,1) NOT NULL DEFAULT 0, + status text NOT NULL DEFAULT 'active', + created_date timestamptz NOT NULL DEFAULT now(), + updated_date timestamptz NOT NULL DEFAULT now(), + + -- Email IS the lookup key: useWorkerProfile resolves a profile by + -- filter({ email }). Enforcing it here makes that assumption safe. + CONSTRAINT worker_profiles_org_email_key UNIQUE (org_id, email), + CONSTRAINT worker_profiles_name_not_blank CHECK (length(btrim(full_name)) > 0), + CONSTRAINT worker_profiles_email_not_blank CHECK (length(btrim(email::text)) > 0), + CONSTRAINT worker_profiles_scores_pct CHECK ( + krow_score BETWEEN 0 AND 100 AND + reliability_score BETWEEN 0 AND 100 AND + profile_completion BETWEEN 0 AND 100 AND + attendance_score BETWEEN 0 AND 100 AND + performance_score BETWEEN 0 AND 100 AND + ai_interview_score BETWEEN 0 AND 100 AND + leadership_potential BETWEEN 0 AND 100 + ), + CONSTRAINT worker_profiles_ratings_range CHECK ( + client_rating BETWEEN 0 AND 5 AND supervisor_rating BETWEEN 0 AND 5 + ), + CONSTRAINT worker_profiles_experience_nonneg CHECK (experience_years >= 0), + CONSTRAINT worker_profiles_xp_nonneg CHECK (xp >= 0), + CONSTRAINT worker_profiles_shifts_nonneg CHECK (shifts_completed >= 0), + CONSTRAINT worker_profiles_json_arrays CHECK ( + jsonb_typeof(experience) = 'array' AND + jsonb_typeof(completed_courses) = 'array' AND + jsonb_typeof(earned_badges) = 'array' AND + jsonb_typeof(capabilities) = 'array' + ) +); + +CREATE INDEX worker_profiles_org_score_idx ON worker_profiles (org_id, krow_score DESC); +CREATE INDEX worker_profiles_courses_gin ON worker_profiles USING gin (completed_courses jsonb_path_ops); + + +-- ── Hiring: applications ──────────────────────────────────────────────────── + +CREATE TABLE job_applications ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + legacy_id text UNIQUE, + org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + job_posting_id uuid NOT NULL REFERENCES job_postings (id) ON DELETE CASCADE, + worker_profile_id uuid REFERENCES worker_profiles (id) ON DELETE SET NULL, + + -- Denormalised: the UI reads job_title straight off the application. + job_title text NOT NULL DEFAULT '', + applicant_name text NOT NULL, + email citext NOT NULL, + phone text NOT NULL DEFAULT '', + years_experience int NOT NULL DEFAULT 0, + english_level english_level NOT NULL DEFAULT 'basic', + certifications text[] NOT NULL DEFAULT '{}', + availability text[] NOT NULL DEFAULT '{}', + skills text[] NOT NULL DEFAULT '{}', + companies_worked text[] NOT NULL DEFAULT '{}', + client_rating numeric(2,1) NOT NULL DEFAULT 0, + professional_summary text NOT NULL DEFAULT '', + cover_letter text NOT NULL DEFAULT '', + selfie_url text NOT NULL DEFAULT '', + + status application_status NOT NULL DEFAULT 'applied', + ai_score int NOT NULL DEFAULT 0, + ai_match_label text NOT NULL DEFAULT '', + ai_summary text NOT NULL DEFAULT '', + ai_strengths text[] NOT NULL DEFAULT '{}', + ai_gaps text[] NOT NULL DEFAULT '{}', + ai_recommendation text NOT NULL DEFAULT '', + score_breakdown jsonb NOT NULL DEFAULT '{}'::jsonb, + screened_at timestamptz, + + created_date timestamptz NOT NULL DEFAULT now(), + updated_date timestamptz NOT NULL DEFAULT now(), + + -- useAssignWorkers already looks an application up by + -- (job_posting_id, lowercased email) before creating one, so it assumes this. + CONSTRAINT job_applications_posting_email_key UNIQUE (job_posting_id, email), + CONSTRAINT job_applications_name_not_blank CHECK (length(btrim(applicant_name)) > 0), + CONSTRAINT job_applications_ai_score_pct CHECK (ai_score BETWEEN 0 AND 100), + CONSTRAINT job_applications_rating_range CHECK (client_rating BETWEEN 0 AND 5), + CONSTRAINT job_applications_experience_nonneg CHECK (years_experience >= 0), + CONSTRAINT job_applications_breakdown_object CHECK (jsonb_typeof(score_breakdown) = 'object'), + -- A screened application must carry the timestamp that says when. + CONSTRAINT job_applications_screened_consistent CHECK ( + status = 'applied' OR screened_at IS NOT NULL OR ai_score = 0 + ) +); + +CREATE INDEX job_applications_org_score_idx ON job_applications (org_id, ai_score DESC); +CREATE INDEX job_applications_posting_status_idx ON job_applications (job_posting_id, status); +CREATE INDEX job_applications_org_status_idx ON job_applications (org_id, status, created_date DESC); +CREATE INDEX job_applications_org_email_idx ON job_applications (org_id, email); +CREATE INDEX job_applications_profile_idx ON job_applications (worker_profile_id) + WHERE worker_profile_id IS NOT NULL; + + +-- ── Hiring: AI interviews ─────────────────────────────────────────────────── + +CREATE TABLE ai_interviews ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + legacy_id text UNIQUE, + org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + application_id uuid NOT NULL REFERENCES job_applications (id) ON DELETE CASCADE, + job_posting_id uuid NOT NULL REFERENCES job_postings (id) ON DELETE CASCADE, + job_title text NOT NULL DEFAULT '', + candidate_name text NOT NULL DEFAULT '', + messages jsonb NOT NULL DEFAULT '[]'::jsonb, + overall_interview_score int NOT NULL DEFAULT 0, + verdict interview_verdict NOT NULL DEFAULT 'maybe', + hire_recommendation text NOT NULL DEFAULT '', + integrity_score int NOT NULL DEFAULT 100, + ai_flags text[] NOT NULL DEFAULT '{}', + category_scores jsonb NOT NULL DEFAULT '{}'::jsonb, + strengths text[] NOT NULL DEFAULT '{}', + concerns text[] NOT NULL DEFAULT '{}', + best_fit_roles text[] NOT NULL DEFAULT '{}', + summary text NOT NULL DEFAULT '', + reasoning text NOT NULL DEFAULT '', + created_date timestamptz NOT NULL DEFAULT now(), + updated_date timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT ai_interviews_score_pct CHECK (overall_interview_score BETWEEN 0 AND 100), + CONSTRAINT ai_interviews_integrity_pct CHECK (integrity_score BETWEEN 0 AND 100), + CONSTRAINT ai_interviews_messages_array CHECK (jsonb_typeof(messages) = 'array'), + CONSTRAINT ai_interviews_categories_object CHECK (jsonb_typeof(category_scores) = 'object') +); + +CREATE INDEX ai_interviews_application_idx ON ai_interviews (application_id); +CREATE INDEX ai_interviews_org_created_idx ON ai_interviews (org_id, created_date DESC); + + +-- ── Hiring: staff (the hire record) ───────────────────────────────────────── + +CREATE TABLE staff ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + legacy_id text UNIQUE, + org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + application_id uuid REFERENCES job_applications (id) ON DELETE SET NULL, + job_posting_id uuid REFERENCES job_postings (id) ON DELETE SET NULL, + worker_profile_id uuid REFERENCES worker_profiles (id) ON DELETE SET NULL, + name text NOT NULL, + email citext NOT NULL, + phone text NOT NULL DEFAULT '', + role text NOT NULL DEFAULT '', + profile_tier profile_tier NOT NULL DEFAULT 'Beginner', + hire_date date NOT NULL, + ai_score int NOT NULL DEFAULT 0, + status staff_status NOT NULL DEFAULT 'onboarding', + client_rating numeric(2,1) NOT NULL DEFAULT 0, + endorsement_text text NOT NULL DEFAULT '', + endorsed_skills text[] NOT NULL DEFAULT '{}', + review_date date, + reviewer_name text NOT NULL DEFAULT '', + created_date timestamptz NOT NULL DEFAULT now(), + updated_date timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT staff_name_not_blank CHECK (length(btrim(name)) > 0), + CONSTRAINT staff_ai_score_pct CHECK (ai_score BETWEEN 0 AND 100), + CONSTRAINT staff_rating_range CHECK (client_rating BETWEEN 0 AND 5), + CONSTRAINT staff_review_after_hire CHECK (review_date IS NULL OR review_date >= hire_date) +); + +CREATE INDEX staff_org_created_idx ON staff (org_id, created_date DESC); +CREATE INDEX staff_org_email_idx ON staff (org_id, email); +CREATE INDEX staff_posting_idx ON staff (job_posting_id) WHERE job_posting_id IS NOT NULL; + + +-- ── Workforce: assignments ────────────────────────────────────────────────── + +CREATE TABLE assignments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + legacy_id text UNIQUE, + org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + job_posting_id uuid NOT NULL REFERENCES job_postings (id) ON DELETE CASCADE, + application_id uuid REFERENCES job_applications (id) ON DELETE SET NULL, + worker_profile_id uuid REFERENCES worker_profiles (id) ON DELETE SET NULL, + -- The email columns stay and stay populated: lib/workforce.js and + -- lib/attendance.js join on them today. + worker_email citext NOT NULL, + worker_name text NOT NULL DEFAULT '', + starts_at timestamptz NOT NULL, + ends_at timestamptz, + status assignment_status NOT NULL DEFAULT 'active', + source text NOT NULL DEFAULT 'owliver', + match_score int, + created_date timestamptz NOT NULL DEFAULT now(), + updated_date timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT assignments_period_ordered CHECK (ends_at IS NULL OR ends_at > starts_at), + CONSTRAINT assignments_match_score_pct CHECK (match_score IS NULL OR match_score BETWEEN 0 AND 100) +); + +CREATE INDEX assignments_posting_active_idx ON assignments (job_posting_id) WHERE status = 'active'; +CREATE INDEX assignments_org_worker_idx ON assignments (org_id, worker_email, starts_at); +-- "Is this person free between X and Y" — lib/workforce.js availability checks. +CREATE INDEX assignments_period_gist ON assignments + USING gist (tstzrange(starts_at, COALESCE(ends_at, 'infinity'::timestamptz))); + + +-- ── Workforce: shift records ──────────────────────────────────────────────── + +CREATE TABLE shift_records ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + legacy_id text UNIQUE, + org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + staff_id uuid REFERENCES staff (id) ON DELETE CASCADE, + assignment_id uuid REFERENCES assignments (id) ON DELETE SET NULL, + job_posting_id uuid REFERENCES job_postings (id) ON DELETE SET NULL, + worker_name text NOT NULL DEFAULT '', + worker_email citext NOT NULL, + role text NOT NULL DEFAULT '', + role_category text NOT NULL DEFAULT '', + shift_date date NOT NULL, + scheduled_start timestamptz NOT NULL, + scheduled_end timestamptz NOT NULL, + scheduled_hours numeric(5,2) NOT NULL, + actual_start timestamptz, + actual_end timestamptz, + actual_hours numeric(5,2) NOT NULL DEFAULT 0, + overtime_hours numeric(5,2) NOT NULL DEFAULT 0, + minutes_late int NOT NULL DEFAULT 0, + status shift_status NOT NULL DEFAULT 'present', + notes text NOT NULL DEFAULT '', + -- NOT defaulted: for a shift this is the instant the shift was worked, and + -- attendanceSeed.js documents that as load-bearing. Writers must supply it. + created_date timestamptz NOT NULL, + updated_date timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT shift_records_schedule_ordered CHECK (scheduled_end > scheduled_start), + CONSTRAINT shift_records_actual_ordered CHECK ( + actual_start IS NULL OR actual_end IS NULL OR actual_end >= actual_start + ), + CONSTRAINT shift_records_hours_nonneg CHECK ( + scheduled_hours >= 0 AND actual_hours >= 0 AND overtime_hours >= 0 + ), + CONSTRAINT shift_records_minutes_late_nonneg CHECK (minutes_late >= 0), + -- An absence has no hours on the clock and nobody was late for it. + CONSTRAINT shift_records_absence_has_no_hours CHECK ( + status NOT IN ('absent', 'no_show') OR (actual_hours = 0 AND minutes_late = 0) + ) +); + +CREATE INDEX shift_records_org_created_idx ON shift_records (org_id, created_date DESC); +CREATE INDEX shift_records_org_staff_idx ON shift_records (org_id, staff_id, shift_date DESC); +CREATE INDEX shift_records_org_category_idx ON shift_records (org_id, role_category, shift_date); +CREATE INDEX shift_records_org_exception_idx ON shift_records (org_id, status) + WHERE status IN ('absent', 'no_show', 'late'); + + +-- ── Proving Ground: evidence ──────────────────────────────────────────────── +-- `media_asset_id` and the `file_assets` table are deliberately absent: object +-- storage is Phase 2. `media_url` is the column the frontend actually writes. + +CREATE TABLE evidence ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + legacy_id text UNIQUE, + org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + course_id uuid REFERENCES courses (id) ON DELETE SET NULL, + worker_profile_id uuid REFERENCES worker_profiles (id) ON DELETE CASCADE, + course_title text NOT NULL DEFAULT '', + skill text NOT NULL DEFAULT '', + worker_email citext NOT NULL, + worker_name text NOT NULL DEFAULT '', + type challenge_type NOT NULL, + media_url text NOT NULL DEFAULT '', + transcript text NOT NULL DEFAULT '', + ai_verdict evidence_verdict NOT NULL DEFAULT 'needs_work', + ai_score int NOT NULL DEFAULT 0, + ai_rubric jsonb NOT NULL DEFAULT '{}'::jsonb, + ai_feedback text NOT NULL DEFAULT '', + supervisor_verified boolean NOT NULL DEFAULT false, + supervisor_name text NOT NULL DEFAULT '', + verified_date timestamptz, + created_date timestamptz NOT NULL DEFAULT now(), + updated_date timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT evidence_ai_score_pct CHECK (ai_score BETWEEN 0 AND 100), + CONSTRAINT evidence_rubric_object CHECK (jsonb_typeof(ai_rubric) = 'object'), + -- A verification must record who did it and when. + CONSTRAINT evidence_verification_complete CHECK ( + supervisor_verified = false + OR (verified_date IS NOT NULL AND length(btrim(supervisor_name)) > 0) + ) +); + +CREATE INDEX evidence_org_worker_idx ON evidence (org_id, worker_email, created_date DESC); +CREATE INDEX evidence_course_idx ON evidence (course_id) WHERE course_id IS NOT NULL; + + +-- ── Activity log ──────────────────────────────────────────────────────────── +-- Append-only. The reference columns are flat and unconstrained on purpose: +-- the frontend calls filter({ position_id }) directly against them, and an +-- activity row must survive the deletion of whatever it describes. + +CREATE TABLE user_activity ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + legacy_id text UNIQUE, + org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + event_type text NOT NULL, + user_id uuid REFERENCES users (id) ON DELETE SET NULL, + user_email citext NOT NULL DEFAULT 'anonymous', + user_name text NOT NULL DEFAULT '', + account_type text NOT NULL DEFAULT 'unknown', + details text NOT NULL DEFAULT '', + position_id uuid, + application_id uuid, + candidate_id uuid, + interview_id uuid, + worker_email citext, + metadata jsonb, + created_date timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT user_activity_event_type_not_blank CHECK (length(btrim(event_type)) > 0) +); + +CREATE INDEX user_activity_org_created_idx ON user_activity (org_id, created_date DESC); +CREATE INDEX user_activity_org_event_idx ON user_activity (org_id, event_type, created_date DESC); +CREATE INDEX user_activity_org_user_idx ON user_activity (org_id, user_email, created_date DESC); +CREATE INDEX user_activity_position_idx ON user_activity (position_id) WHERE position_id IS NOT NULL; +CREATE INDEX user_activity_application_idx ON user_activity (application_id) WHERE application_id IS NOT NULL; + diff --git a/migrations/000002_application_interview_id.down.sql b/migrations/000002_application_interview_id.down.sql new file mode 100644 index 0000000..1e494d9 --- /dev/null +++ b/migrations/000002_application_interview_id.down.sql @@ -0,0 +1,17 @@ +-- Reverses 000002, dropping each added column. Indexes and the check +-- constraint go with their columns. +SET search_path = public; + +DROP INDEX IF EXISTS public.job_applications_interview_idx; + +ALTER TABLE public.job_applications + DROP COLUMN IF EXISTS interview_id; + +ALTER TABLE public.courses + DROP COLUMN IF EXISTS training_outline; + +ALTER TABLE public.worker_profiles + DROP CONSTRAINT IF EXISTS worker_profiles_score_breakdown_object; + +ALTER TABLE public.worker_profiles + DROP COLUMN IF EXISTS score_breakdown; diff --git a/migrations/000002_application_interview_id.up.sql b/migrations/000002_application_interview_id.up.sql new file mode 100644 index 0000000..5da472c --- /dev/null +++ b/migrations/000002_application_interview_id.up.sql @@ -0,0 +1,82 @@ +-- ============================================================================ +-- Frontend reconciliation gaps +-- +-- Three columns the frontend reads or writes but migration 000001 had no place +-- for. All three were found by sweeping runtime write paths, not just seed data. +-- +-- ── 1. job_applications.interview_id ────────────────────────────────────── +-- +-- WRITE components/krow/AIInterviewModal.jsx:180 +-- PATCH {status:'interview', interview_id, ai_score} — reachable from +-- admin/CandidateProfile.jsx, admin/Candidates.jsx, PositionDetail.jsx +-- READ components/krow/CandidateExpandedDetails.jsx:41,142 +-- via CandidateCard.jsx ← admin/Candidates.jsx, PositionDetail.jsx +-- SEED 5 of 24 applications in src/api/seed.js carry it +-- +-- Deliberately NOT a foreign key. `app_devon` references `int_devon`, for which +-- no AIInterview record exists in the seed; a REFERENCES constraint would +-- either fail the seed or force that value to NULL, and nulling it would be +-- silently transforming source data. The column is a soft reference, which is +-- also how the frontend treats it — every read is a truthiness check, never a +-- lookup. +-- ============================================================================ + +SET search_path = public; + +ALTER TABLE public.job_applications + ADD COLUMN interview_id uuid; + +COMMENT ON COLUMN public.job_applications.interview_id IS + 'Soft reference to ai_interviews.id. Intentionally unconstrained: seed data ' + 'contains a dangling reference, and the frontend only ever tests it for ' + 'presence.'; + +CREATE INDEX job_applications_interview_idx + ON public.job_applications (interview_id) + WHERE interview_id IS NOT NULL; + + +-- ── 2. courses.training_outline ─────────────────────────────────────────── +-- +-- WRITE components/forge/AddSkillTraining.jsx:103 ← pages/University.jsx +-- (mounted at /admin/university) +-- READ components/forge/challengeMeta.js:139, ai-assistant/insights.js:177, +-- ai-assistant/capabilities/admin.js:1547, lib/skills/actions.js:369 +-- SEED 0 of 40 courses carry it — the Forge authoring flow writes it, and +-- every shipped course predates that flow. +-- +-- text[] rather than jsonb: the writer is +-- `form.outline.map((s) => s.trim()).filter(Boolean)`, a plain list of strings, +-- and every reader guards with Array.isArray then filters. This matches the +-- shape of completion_criteria and verification_criteria on the same table. + +ALTER TABLE public.courses + ADD COLUMN training_outline text[] NOT NULL DEFAULT '{}'; + +COMMENT ON COLUMN public.courses.training_outline IS + 'Ordered written training steps, authored through the Forge flow. Empty for ' + 'every course that predates it.'; + + +-- ── 3. worker_profiles.score_breakdown ──────────────────────────────────── +-- +-- WRITE lib/krowScore.js:64 recalcProfilePatch() → spread into the profile +-- patch at lib/krowHooks.js:682, the live challenge-submission path +-- reached through CourseDetail.jsx. +-- READ No reader consumes it *from a profile*: every score_breakdown reader +-- in the frontend works on a job application. The column exists so the +-- write lands rather than being silently discarded. +-- +-- jsonb, mirroring job_applications.score_breakdown, which holds the output of +-- the same scoring engine. + +ALTER TABLE public.worker_profiles + ADD COLUMN score_breakdown jsonb NOT NULL DEFAULT '{}'::jsonb; + +ALTER TABLE public.worker_profiles + ADD CONSTRAINT worker_profiles_score_breakdown_object + CHECK (jsonb_typeof(score_breakdown) = 'object'); + +COMMENT ON COLUMN public.worker_profiles.score_breakdown IS + 'Per-dimension KROW score detail from recalcProfilePatch(). Written by the ' + 'challenge flow; no frontend reader consumes it from a profile yet.'; diff --git a/migrations/000003_drop_screened_consistent_check.down.sql b/migrations/000003_drop_screened_consistent_check.down.sql new file mode 100644 index 0000000..2b4c03d --- /dev/null +++ b/migrations/000003_drop_screened_consistent_check.down.sql @@ -0,0 +1,12 @@ +-- Restores the constraint dropped by 000003. +-- +-- NOTE: this will FAIL on any database holding the seeded demo dataset, because +-- that data is what the constraint rejects. That is the point of dropping it. +-- Roll back only on a database whose applications satisfy the predicate. + +SET search_path = public; + +ALTER TABLE public.job_applications + ADD CONSTRAINT job_applications_screened_consistent CHECK ( + status = 'applied' OR screened_at IS NOT NULL OR ai_score = 0 + ); diff --git a/migrations/000003_drop_screened_consistent_check.up.sql b/migrations/000003_drop_screened_consistent_check.up.sql new file mode 100644 index 0000000..74f4c57 --- /dev/null +++ b/migrations/000003_drop_screened_consistent_check.up.sql @@ -0,0 +1,28 @@ +-- ============================================================================ +-- Drop job_applications_screened_consistent +-- +-- A defect in migration 000001. The constraint reads: +-- +-- status = 'applied' OR screened_at IS NOT NULL OR ai_score = 0 +-- +-- which asserts that a screened application records *when* it was screened. +-- The frontend makes no such promise: `screened_at` is never read and never +-- written anywhere in the repository, and none of the 24 seeded applications +-- carries it. The column and the constraint both came out of the backend +-- blueprint rather than out of repository evidence. +-- +-- The effect was that 9 of 24 seeded applications were rejected — exactly the +-- 9 AI-scored ones behind the "9 scored, averaging 76" regression anchor. +-- +-- 000001 is already applied and is immutable, so the fix lands here. All 52 +-- other CHECK constraints from 000001 were verified against all 245 seeded +-- records and hold. +-- +-- The `screened_at` column is kept: it is nullable and unused, and removing it +-- is a separate cleanup rather than part of this fix. +-- ============================================================================ + +SET search_path = public; + +ALTER TABLE public.job_applications + DROP CONSTRAINT IF EXISTS job_applications_screened_consistent; diff --git a/migrations/000004_auth_sessions.down.sql b/migrations/000004_auth_sessions.down.sql new file mode 100644 index 0000000..6f1bd97 --- /dev/null +++ b/migrations/000004_auth_sessions.down.sql @@ -0,0 +1,16 @@ +-- Reverses 000004. +-- +-- Drops exactly what the up migration created and nothing else: no CASCADE on +-- a schema, no DROP SCHEMA, no DROP DATABASE, and no touch to users beyond +-- removing the index 000004 added. users_org_email_key predates this migration +-- and is left alone. +-- +-- Dropping `sessions` logs everyone out. That is the correct meaning of +-- rolling back the authentication foundation, and it destroys no application +-- data: every row in this table is a credential, not a record. + +SET search_path = public; + +DROP TABLE IF EXISTS public.sessions; + +DROP INDEX IF EXISTS public.users_email_global_key; diff --git a/migrations/000004_auth_sessions.up.sql b/migrations/000004_auth_sessions.up.sql new file mode 100644 index 0000000..3273350 --- /dev/null +++ b/migrations/000004_auth_sessions.up.sql @@ -0,0 +1,119 @@ +-- ============================================================================ +-- Krow — authentication foundation +-- +-- Phase 3B. This migration adds the two schema facts authentication needs and +-- nothing else: +-- +-- 1. users.email is globally unique, because login identifies a user by +-- email alone. +-- 2. a `sessions` table, because sessions are server-side and opaque. +-- +-- Deliberately NOT here, per the Phase 3B decisions: +-- roles, permissions, organization_members, credentials, refresh_tokens. +-- `users.role` is already the authorization field and `users.password_hash` +-- already exists; neither needs a table of its own. +-- +-- No login, logout, middleware or enforcement ships with this migration. It is +-- schema only. +-- +-- Target schema: public. No system schema is read or written. +-- ============================================================================ + +-- Atomicity comes from golang-migrate: the postgres driver sends this file as a +-- single simple query, which Postgres executes inside one implicit transaction. +-- Any failure below rolls the whole migration back. +SET search_path = public; + + +-- ── users.email — global uniqueness ───────────────────────────────────────── +-- +-- 000001 constrains (org_id, email). That is the right key for a tenant-scoped +-- directory, and the wrong key for a login form: `POST /auth/login` will be +-- given an email and a password and nothing else, so an email that resolved to +-- two users in two organizations would have no single answer. +-- +-- The column is `citext`, so this index is case-insensitive for free — +-- "Demo@Krow.app" and "demo@krow.app" collide, which is what a login form +-- needs. A plain btree over a citext column uses the type's own comparison; no +-- lower() expression is required, and using one here would in fact build a +-- *different*, case-sensitive index. +-- +-- users_org_email_key is left in place. It is now implied by this index and +-- therefore redundant, but dropping it is a change to the existing table that +-- authentication does not need, and a redundant unique constraint costs one +-- index write per user row — of which there is currently one. +-- +-- Verified before writing this migration: no two rows in the target database +-- share an email, so the index builds without a conflict. +CREATE UNIQUE INDEX users_email_global_key ON public.users (email); + +COMMENT ON INDEX public.users_email_global_key IS + 'Login identity. Email must resolve to exactly one user across every ' + 'organization, because the login form supplies no organization.'; + + +-- ── sessions ──────────────────────────────────────────────────────────────── +-- +-- A session is a row, not a token payload. The browser holds an opaque random +-- string in an HttpOnly cookie; this table holds only SHA-256 of that string, +-- so a dump of this table cannot be replayed as a login. +-- +-- token_hash is `text` holding lowercase hex rather than bytea: it is 64 ASCII +-- bytes either way after TOAST considerations, it is greppable in psql during +-- development, and it matches how password_hash is already stored. The CHECK +-- pins the format, so a caller cannot accidentally store a raw token here — +-- a raw token is base64url of 32 bytes and fails the pattern. +-- +-- Two expiries, because Phase 3B decision 3 allows sliding expiry and also +-- requires that a session cannot live forever: +-- +-- expires_at moves forward as the session is used (the sliding +-- window: 12 hours normally, 30 days with Remember Me). +-- absolute_expires_at is fixed at creation and never moves. Once it passes, +-- the session is dead no matter how recently it was +-- used, and the user authenticates again. +-- +-- Without the second column the first can be slid indefinitely, which is +-- exactly the "session that lives forever" the decision rules out. + +CREATE TABLE sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE, + token_hash text NOT NULL, + expires_at timestamptz NOT NULL, + absolute_expires_at timestamptz NOT NULL, + created_date timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz NOT NULL DEFAULT now(), + + -- UNIQUE is implemented by PostgreSQL as a btree index on token_hash, which + -- is also the index every session lookup uses: authentication hashes the + -- cookie and probes this one column. It is both the uniqueness guarantee and + -- the lookup path; a second index on the same column would be dead weight. + CONSTRAINT sessions_token_hash_key UNIQUE (token_hash), + + CONSTRAINT sessions_token_hash_sha256 CHECK (token_hash ~ '^[0-9a-f]{64}$'), + CONSTRAINT sessions_absolute_after_created CHECK (absolute_expires_at > created_date), + CONSTRAINT sessions_within_absolute CHECK (expires_at <= absolute_expires_at) +); + +-- ON DELETE CASCADE, not SET NULL and not RESTRICT: a deleted user must not +-- leave a live session behind that still authenticates as them. + +-- Revoking every session for one user, and the FK's own cascade check. +CREATE INDEX sessions_user_idx ON sessions (user_id); + +-- The periodic sweep of dead rows. Ordered by the column it filters on. +CREATE INDEX sessions_expires_idx ON sessions (expires_at); + +COMMENT ON TABLE sessions IS + 'Server-side sessions. The raw token exists only in the client HttpOnly ' + 'cookie; this table stores SHA-256 of it and never the token itself.'; + +COMMENT ON COLUMN sessions.token_hash IS + 'Lowercase hex SHA-256 of the session token. Never the token.'; + +COMMENT ON COLUMN sessions.expires_at IS + 'Sliding expiry. Moved forward on use; never past absolute_expires_at.'; + +COMMENT ON COLUMN sessions.absolute_expires_at IS + 'Hard ceiling, fixed at creation. A session cannot outlive it.'; diff --git a/migrations/000005_agent_skill_definitions.down.sql b/migrations/000005_agent_skill_definitions.down.sql new file mode 100644 index 0000000..ad85862 --- /dev/null +++ b/migrations/000005_agent_skill_definitions.down.sql @@ -0,0 +1,22 @@ +-- Reverses 000005. +-- +-- Drops exactly the two tables it created and nothing else. No CASCADE on a +-- schema, no DROP SCHEMA, no DROP DATABASE, and no touch to any table that +-- predates this migration. Indexes and constraints go with their tables. +-- +-- No enum types were created by 000005 — the two status vocabularies are text +-- with CHECK constraints — so there is nothing left behind to clean up. +-- +-- Rolling this back destroys every authored agent and skill definition. That is +-- the correct meaning of reversing the migration that introduced them, and it +-- reaches nothing else: shipped definitions live in Git, and the account +-- preferences these tables replaced are untouched by both directions of 000005. +-- +-- Dropped in reverse creation order. The two tables do not reference each +-- other, so the order is convention rather than necessity. + +SET search_path = public; + +DROP TABLE IF EXISTS public.skill_definitions; + +DROP TABLE IF EXISTS public.agent_definitions; diff --git a/migrations/000005_agent_skill_definitions.up.sql b/migrations/000005_agent_skill_definitions.up.sql new file mode 100644 index 0000000..acd25fb --- /dev/null +++ b/migrations/000005_agent_skill_definitions.up.sql @@ -0,0 +1,293 @@ +-- ============================================================================ +-- Krow — authored agent and skill definitions +-- +-- Phase 4C. Two tables, and deliberately only two. +-- +-- WHAT THIS IS FOR +-- +-- An agent and a skill are each a Markdown file with YAML frontmatter. Three +-- tiers of them exist, and only two live here: +-- +-- shipped src/agents/**/*.md, src/skills/**/*.md — product source, +-- versioned in Git, bundled at build time. NO ROWS HERE. They +-- are code: putting them in a table would trade `git log`, +-- code review and atomic deploy for nothing, and would make +-- every shipped-definition change a data migration. +-- organization authored in the app, shared across one tenant. +-- personal authored in the app, private to one user. +-- +-- Before this migration the last two lived in `user_preferences.extra`, a +-- jsonb blob with no owner, no tenancy, no size bound, no server-side +-- validation and no query surface — and returned in full by GET /api/v1/me on +-- every page load. This migration is that move. +-- +-- WHY TWO TABLES AND NOT ONE +-- +-- Agents and skills do not share a lifecycle, and the difference is not +-- incidental: +-- +-- agents status draft | published | archived, plus an integer version that +-- only goes up. They are published artefacts. +-- skills status active | inactive, and NO version at all — the frontend has +-- no notion of a skill version and none is invented here. +-- +-- One table would need a union CHECK permitting `version 5, status inactive`, +-- and a version column that is forever 1 for half the rows. Two tables cost a +-- little repetition and buy a schema where every row is meaningful. +-- +-- WHAT IS DELIBERATELY ABSENT +-- +-- definition_versions nothing retains prior Markdown; no rollback +-- feature exists to serve. +-- definition_permissions the `permissions:` frontmatter block stays inside +-- the Markdown, parsed and unenforced, until its +-- semantics are defined (Phase 4H). +-- agent_skills `skills:` names ids in a namespace that includes +-- agent_subagents SHIPPED definitions, which have no rows here. A +-- join table would need foreign keys to rows that do +-- not exist. Resolution stays in the registry. +-- agent_knowledge embedded in frontmatter; no corpus exists. +-- conversations deferred. +-- +-- `disabledSkills` and `removedSkills` also stay where they are, in +-- user_preferences.extra. They are per-account arrays of skill *ids* — mostly +-- shipped ids — so they are suppression preferences over a namespace, not +-- definitions, and they are already in the right place. +-- +-- Target schema: public. No system schema is read or written. +-- ============================================================================ + +-- Atomicity comes from golang-migrate: the postgres driver sends this file as a +-- single simple query, which Postgres executes inside one implicit transaction. +-- Any failure below rolls the whole migration back. +SET search_path = public; + + +-- ── agent_definitions ─────────────────────────────────────────────────────── + +CREATE TABLE agent_definitions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + + -- The author-facing id — `id:` in the frontmatter, the address that skills, + -- subagents and the shadow-by-id merge all refer to. NOT globally unique: + -- the whole point of shadowing is that a personal definition may carry the + -- same id as an organization one, which may carry the same id as a shipped + -- one. See the two partial unique indexes below for what IS unique. + definition_id text NOT NULL, + + -- Tenancy. NOT NULL on a personal definition too: a user belongs to exactly + -- one organization, so a personal definition is always inside a tenant, and + -- carrying org_id means the organization predicate applies to every read + -- whether or not the ownership predicate does. Defence in depth for one + -- column. + org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + + visibility text NOT NULL, + + -- Ownership, in two columns because the two tiers have genuinely different + -- deletion semantics and one column cannot carry both: + -- + -- owner_user_id set ONLY for a personal definition. CASCADE: a personal + -- definition dies with its owner, because there is nobody + -- else it could belong to. + -- created_by always the author. SET NULL: an organization-shared + -- definition must survive its author leaving the company. + -- Nullable for exactly that reason, and because that is + -- already this schema's pattern — job_postings.created_by. + owner_user_id uuid REFERENCES users (id) ON DELETE CASCADE, + created_by uuid REFERENCES users (id) ON DELETE SET NULL, + + -- The definition, verbatim. THIS IS THE AUTHORITATIVE ARTEFACT: a definition + -- must survive a round trip to a .md file on disk unchanged, so the Markdown + -- is the record and the columns below are derived from it. + markdown text NOT NULL, + + -- ── Projections ────────────────────────────────────────────────────────── + -- Everything below is parsed OUT of `markdown` by the server, never accepted + -- from a request body, and rebuildable by re-parsing every row. They exist so + -- that "this organization's published agents" is a query rather than a parse + -- of every blob, and so version conflicts can be detected with a predicate + -- rather than a read-modify-write in the browser. + status text NOT NULL DEFAULT 'draft', + + -- Monotonic. A first publish keeps its version; republishing moves it on, so + -- "what is live" is always a specific number. + version integer NOT NULL DEFAULT 1, + + name text NOT NULL DEFAULT '', + description text NOT NULL DEFAULT '', + -- text[] rather than jsonb, matching courses.training_outline: this is a + -- plain list of strings and every reader treats it as one. + pages text[] NOT NULL DEFAULT '{}', + + created_date timestamptz NOT NULL DEFAULT now(), + updated_date timestamptz NOT NULL DEFAULT now(), + + -- The format the frontend validator already enforces, restated here so the + -- database refuses what the application would have refused. Lower-case + -- letters, digits and dashes, not starting with a dash. + CONSTRAINT agent_definitions_definition_id_format + CHECK (definition_id ~ '^[a-z0-9][a-z0-9-]*$'), + + CONSTRAINT agent_definitions_visibility_check + CHECK (visibility IN ('personal', 'organization')), + + -- The ownership invariant, stated once and in both directions: a personal + -- definition HAS an owner, an organization definition has NONE. Written as an + -- equality of two booleans rather than two OR'd implications, because that is + -- the whole rule in one line and cannot be half-satisfied. + CONSTRAINT agent_definitions_visibility_owner + CHECK ((visibility = 'personal') = (owner_user_id IS NOT NULL)), + + -- text + CHECK rather than a PostgreSQL enum, following users.role and + -- users.status. A status vocabulary that may grow is easier to widen with an + -- ALTER of a constraint than with ALTER TYPE ... ADD VALUE, and it keeps the + -- down migration to a table drop with no type left behind. + CONSTRAINT agent_definitions_status_check + CHECK (status IN ('draft', 'published', 'archived')), + + CONSTRAINT agent_definitions_version_check + CHECK (version >= 1), + + -- A bound on the blob, which is the other half of moving definitions out of + -- user_preferences.extra. The largest definition shipped with the product is + -- 3,156 bytes and the median is 1,354, so 65,536 is roughly twenty times the + -- biggest thing anyone has actually written — unreachable by legitimate + -- authoring, and low enough that no single row can be used to bloat a + -- response. An empty definition cannot parse, so zero length is refused too. + -- + -- `length()` counts CHARACTERS, which is the semantic this schema already + -- uses (organizations_name_not_blank, users_email_not_blank). A worst-case + -- 4-byte-per-character document would therefore be up to 256 KiB on disk; + -- that is accepted deliberately in exchange for one consistent rule. + CONSTRAINT agent_definitions_markdown_size + CHECK (length(markdown) BETWEEN 1 AND 65536) +); + +-- Uniqueness, per tier. Partial rather than whole-table because the two tiers +-- are keyed on different columns: a personal definition is unique to its owner, +-- an organization definition to its tenant. Partial also keeps each index to +-- only the rows it governs. +-- +-- (A plain UNIQUE (owner_user_id, definition_id) would technically also work, +-- because NULLs are distinct by default and organization rows all have a NULL +-- owner — but it would be relying on a subtlety to express a rule, which is +-- how the rule gets misread later.) +CREATE UNIQUE INDEX agent_definitions_personal_key + ON agent_definitions (owner_user_id, definition_id) + WHERE visibility = 'personal'; + +CREATE UNIQUE INDEX agent_definitions_org_key + ON agent_definitions (org_id, definition_id) + WHERE visibility = 'organization'; + +-- Listing one organization's definitions, split by tier. Also covers the +-- org_id foreign key, which PostgreSQL does not index on its own. +CREATE INDEX agent_definitions_org_visibility_idx + ON agent_definitions (org_id, visibility); + +-- Listing one user's own definitions, and the owner_user_id foreign key's +-- cascade check. +CREATE INDEX agent_definitions_owner_idx + ON agent_definitions (owner_user_id); + +-- The runtime's own query: the agents that are actually live in a tenant. An +-- unpublished agent contributes nothing at runtime, so the index carries only +-- published rows — the same shape as job_postings_org_active_idx. +CREATE INDEX agent_definitions_published_idx + ON agent_definitions (org_id, visibility) + WHERE status = 'published'; + +-- There is deliberately NO index on created_by. It is attribution only: no +-- listing is keyed by it, and its ON DELETE SET NULL scan happens when a user +-- is deleted, which is rare and against a small table. An index would cost a +-- write on every definition change to serve nothing. + +COMMENT ON TABLE agent_definitions IS + 'Agent definitions authored in the application. Shipped agents live in Git ' + 'under src/agents/ and have no rows here.'; + +COMMENT ON COLUMN agent_definitions.definition_id IS + 'Author-facing id from the frontmatter. Unique per owner or per organization, ' + 'never globally: shadow-by-id is the point.'; + +COMMENT ON COLUMN agent_definitions.markdown IS + 'The definition verbatim, and the authoritative record. Every other column ' + 'except the identity and ownership ones is parsed out of this.'; + +COMMENT ON COLUMN agent_definitions.owner_user_id IS + 'Set only when visibility = personal. Organization definitions have none.'; + +COMMENT ON COLUMN agent_definitions.created_by IS + 'The author, for attribution. Nullable so a shared definition survives its ' + 'author being deleted.'; + + +-- ── skill_definitions ─────────────────────────────────────────────────────── +-- +-- The same shape, minus `version`. Skills have no version and no publish step +-- in the product: a skill is active or inactive, and that is the whole of its +-- lifecycle. Adding a version column "for symmetry" would be inventing a +-- concept the frontend does not have and cannot set. + +CREATE TABLE skill_definitions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + definition_id text NOT NULL, + org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + visibility text NOT NULL, + owner_user_id uuid REFERENCES users (id) ON DELETE CASCADE, + created_by uuid REFERENCES users (id) ON DELETE SET NULL, + markdown text NOT NULL, + + -- Projections, as above. + status text NOT NULL DEFAULT 'active', + name text NOT NULL DEFAULT '', + description text NOT NULL DEFAULT '', + pages text[] NOT NULL DEFAULT '{}', + + created_date timestamptz NOT NULL DEFAULT now(), + updated_date timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT skill_definitions_definition_id_format + CHECK (definition_id ~ '^[a-z0-9][a-z0-9-]*$'), + + CONSTRAINT skill_definitions_visibility_check + CHECK (visibility IN ('personal', 'organization')), + + CONSTRAINT skill_definitions_visibility_owner + CHECK ((visibility = 'personal') = (owner_user_id IS NOT NULL)), + + CONSTRAINT skill_definitions_status_check + CHECK (status IN ('active', 'inactive')), + + CONSTRAINT skill_definitions_markdown_size + CHECK (length(markdown) BETWEEN 1 AND 65536) +); + +CREATE UNIQUE INDEX skill_definitions_personal_key + ON skill_definitions (owner_user_id, definition_id) + WHERE visibility = 'personal'; + +CREATE UNIQUE INDEX skill_definitions_org_key + ON skill_definitions (org_id, definition_id) + WHERE visibility = 'organization'; + +CREATE INDEX skill_definitions_org_visibility_idx + ON skill_definitions (org_id, visibility); + +CREATE INDEX skill_definitions_owner_idx + ON skill_definitions (owner_user_id); + +-- The runtime loads active skills; an inactive one is registered and switched +-- off. Partial for the same reason as the agent index above. +CREATE INDEX skill_definitions_active_idx + ON skill_definitions (org_id, visibility) + WHERE status = 'active'; + +COMMENT ON TABLE skill_definitions IS + 'Skill definitions authored in the application. Shipped skills live in Git ' + 'under src/skills/ and have no rows here. Skills have no version: their ' + 'lifecycle is active or inactive.'; + +COMMENT ON COLUMN skill_definitions.markdown IS + 'The definition verbatim, and the authoritative record.'; diff --git a/scripts/cases.mjs b/scripts/cases.mjs new file mode 100644 index 0000000..44db66f --- /dev/null +++ b/scripts/cases.mjs @@ -0,0 +1,268 @@ +/** + * Adversarial case corpus for Phase 4D parser conformance. + * + * Each case is raw bytes as an author could actually produce them. Nothing here + * is normalized on the way in: the point is what the two parsers do with the + * awkward form, so the awkward form is what is stored. + */ + +const SKILL = [ + '---', + 'id: sample-skill', + 'name: Sample Skill', + 'description: A sample skill.', + 'pages:', + ' - candidates', + 'status: active', + 'actions:', + ' - navigate_to_candidates', + '---', + '', + '# Sample Skill', + '', + '## Purpose', + '', + '- Read the pipeline.', + '', + '## Capabilities', + '', + '- Summarize candidates.', + '', +].join('\n'); + +const AGENT = [ + '---', + 'id: sample-agent', + 'name: Sample Agent', + 'description: A sample agent.', + 'icon: users', + 'status: published', + 'version: 2', + 'reasoning: balanced', + 'trigger: Use on candidates.', + 'pages:', + ' - candidates', + 'skills:', + ' - candidate-search', + 'starters:', + ' - label: Who is waiting?', + ' prompt: Who is waiting on a decision?', + 'permissions:', + ' owner: demo@krow.app', + ' access: all', + '---', + '', + '# Sample Agent', + '', + '## Instructions', + '', + 'Answer about candidates.', + '', + '## Purpose', + '', + '- Report the pipeline.', + '', +].join('\n'); + +/** A skill body, with the frontmatter lines replaced wholesale. */ +const skillWith = (fmLines) => ['---', ...fmLines, '---', '', '# Sample Skill', '', '## Purpose', '', '- Read the pipeline.', ''].join('\n'); +const agentWith = (fmLines) => ['---', ...fmLines, '---', '', '# Sample Agent', '', '## Instructions', '', 'Answer.', ''].join('\n'); + +const BASE_SKILL_FM = [ + 'id: sample-skill', + 'name: Sample Skill', + 'description: A sample skill.', + 'pages:', + ' - candidates', +]; + +const BASE_AGENT_FM = [ + 'id: sample-agent', + 'name: Sample Agent', + 'description: A sample agent.', + 'pages:', + ' - candidates', +]; + +/** BASE_SKILL_FM with one line appended. */ +const skillPlus = (...extra) => skillWith([...BASE_SKILL_FM, ...extra]); +const agentPlus = (...extra) => agentWith([...BASE_AGENT_FM, ...extra]); + +export const CASES = [ + /* ── Baselines ────────────────────────────────────────────────────────── */ + { name: 'baseline-skill', kind: 'skill', raw: SKILL }, + { name: 'baseline-agent', kind: 'agent', raw: AGENT }, + + /* ── Byte-level shape ─────────────────────────────────────────────────── */ + { name: 'utf8-bom', kind: 'skill', raw: '' + SKILL }, + { name: 'utf8-bom-agent', kind: 'agent', raw: '' + AGENT }, + { name: 'crlf', kind: 'skill', raw: SKILL.replace(/\n/g, '\r\n') }, + { name: 'crlf-agent', kind: 'agent', raw: AGENT.replace(/\n/g, '\r\n') }, + { name: 'cr-only', kind: 'skill', raw: SKILL.replace(/\n/g, '\r') }, + { name: 'bom-crlf-blankline', kind: 'skill', raw: '\r\n' + SKILL.replace(/\n/g, '\r\n') }, + { name: 'leading-blank-line', kind: 'skill', raw: '\n' + SKILL }, + { name: 'multiple-leading-blank-lines', kind: 'skill', raw: '\n\n\n' + SKILL }, + { name: 'leading-spaces-then-blank-lines', kind: 'skill', raw: ' \n \n' + SKILL }, + { name: 'no-trailing-newline', kind: 'skill', raw: SKILL.replace(/\n+$/, '') }, + { name: 'many-trailing-newlines', kind: 'skill', raw: SKILL + '\n\n\n' }, + { name: 'trailing-spaces-on-values', kind: 'skill', raw: skillWith(BASE_SKILL_FM.map((l) => l + ' ')) }, + { name: 'trailing-ws-after-open-fence', kind: 'skill', raw: SKILL.replace(/^---/, '--- ') }, + { name: 'trailing-tab-after-close-fence', kind: 'skill', raw: SKILL.replace(/\n---\n/, '\n---\t\n') }, + { name: 'frontmatter-only-no-body', kind: 'skill', raw: '---\n' + BASE_SKILL_FM.join('\n') + '\n---' }, + { name: 'frontmatter-only-trailing-newline', kind: 'skill', raw: '---\n' + BASE_SKILL_FM.join('\n') + '\n---\n' }, + + /* ── Scalars ──────────────────────────────────────────────────────────── */ + { name: 'double-quoted-scalar', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: "Sample Skill"', 'description: "A sample."', 'pages:', ' - candidates']) }, + { name: 'single-quoted-scalar', kind: 'skill', raw: skillWith(["id: sample-skill", "name: 'Sample Skill'", "description: 'A sample.'", 'pages:', ' - candidates']) }, + { name: 'colon-in-quoted-string', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: "Sample: Skill"', 'description: "Note: read this."', 'pages:', ' - candidates']) }, + { name: 'colon-in-unquoted-string', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: Sample Skill', 'description: Note: read this.', 'pages:', ' - candidates']) }, + { name: 'hash-in-quoted-string', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: "Shift #1"', 'description: "Tag #ops"', 'pages:', ' - candidates']) }, + { name: 'hash-unquoted-trailing-comment', kind: 'skill', raw: skillPlus('category: ops # a trailing comment') }, + { name: 'hash-unquoted-midword', kind: 'skill', raw: skillPlus('category: ops#1') }, + { name: 'doubled-quote-escape', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: "She said ""go"""', 'description: A sample.', 'pages:', ' - candidates']) }, + { name: 'full-line-comment', kind: 'skill', raw: skillWith(['# a comment line', ...BASE_SKILL_FM]) }, + { name: 'empty-scalar', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: Sample Skill', 'description:', 'pages:', ' - candidates']) }, + { name: 'tilde-scalar', kind: 'skill', raw: skillPlus('category: ~') }, + { name: 'null-scalar', kind: 'skill', raw: skillPlus('category: null') }, + { name: 'boolean-scalar', kind: 'agent', raw: agentPlus('webSearch: true') }, + { name: 'integer-scalar', kind: 'agent', raw: agentPlus('version: 3') }, + { name: 'float-scalar', kind: 'agent', raw: agentPlus('version: 1.5') }, + { name: 'negative-integer', kind: 'agent', raw: agentPlus('version: -2') }, + + /* ── Collections ──────────────────────────────────────────────────────── */ + { name: 'empty-array', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: Sample Skill', 'description: A sample.', 'pages:', ' - candidates', 'actions:']) }, + { name: 'inline-flow-array', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: Sample Skill', 'description: A sample.', 'pages: [candidates]']) }, + { name: 'inline-flow-map', kind: 'agent', raw: agentPlus('permissions: {owner: a, access: all}') }, + { name: 'sequence-of-mappings', kind: 'agent', raw: AGENT }, + { name: 'nested-mapping', kind: 'agent', raw: agentWith([...BASE_AGENT_FM, 'permissions:', ' owner: demo@krow.app', ' access: specific', ' people:', ' - user: a@b.c', ' role: editor']) }, + { name: 'dash-alone-nested-block', kind: 'agent', raw: agentWith([...BASE_AGENT_FM, 'starters:', ' -', ' label: Hello', ' prompt: Hello there']) }, + { name: 'tab-indented-sequence', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: Sample Skill', 'description: A sample.', 'pages:', '\t- candidates']) }, + { name: 'four-space-indent', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: Sample Skill', 'description: A sample.', 'pages:', ' - candidates']) }, + { name: 'ragged-indent', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: Sample Skill', 'description: A sample.', 'pages:', ' - candidates', ' - positions']) }, + + /* ── Multiline / unsupported YAML ─────────────────────────────────────── */ + { name: 'block-scalar-literal', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: Sample Skill', 'description: |', ' Line one.', ' Line two.', 'pages:', ' - candidates']) }, + { name: 'block-scalar-folded', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: Sample Skill', 'description: >', ' Line one.', 'pages:', ' - candidates']) }, + { name: 'anchor-and-alias', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: &n Sample Skill', 'description: *n', 'pages:', ' - candidates']) }, + { name: 'multi-document', kind: 'skill', raw: skillWith([...BASE_SKILL_FM, '---', 'id: second']) }, + + /* ── Keys ─────────────────────────────────────────────────────────────── */ + { name: 'duplicate-key', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: First Name', 'name: Second Name', 'description: A sample.', 'pages:', ' - candidates']) }, + { name: 'duplicate-key-array', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: Sample Skill', 'description: A sample.', 'pages:', ' - candidates', 'pages:', ' - positions']) }, + { name: 'unsupported-frontmatter-field', kind: 'skill', raw: skillPlus('unknownField: whatever') }, + { name: 'unsupported-field-agent', kind: 'agent', raw: agentPlus('nonsense: 1') }, + { name: 'key-with-space', kind: 'skill', raw: skillWith(['id: sample-skill', 'my key: value', 'name: Sample Skill', 'description: A sample.', 'pages:', ' - candidates']) }, + { name: 'uppercase-key', kind: 'skill', raw: skillPlus('Category: Ops') }, + + /* ── Malformed ────────────────────────────────────────────────────────── */ + { name: 'malformed-yaml-bare-line', kind: 'skill', raw: skillWith(['id: sample-skill', 'this is not a pair', 'name: Sample Skill', 'pages:', ' - candidates']) }, + { name: 'malformed-open-fence-two-dashes', kind: 'skill', raw: SKILL.replace(/^---/, '--') }, + { name: 'malformed-open-fence-four-dashes', kind: 'skill', raw: SKILL.replace(/^---/, '----') }, + { name: 'malformed-open-fence-indented', kind: 'skill', raw: ' ' + SKILL }, + { name: 'malformed-open-fence-text-after', kind: 'skill', raw: SKILL.replace(/^---/, '---yaml') }, + { name: 'malformed-close-fence-two-dashes', kind: 'skill', raw: '---\n' + BASE_SKILL_FM.join('\n') + '\n--\n\n# Body\n' }, + { name: 'malformed-close-fence-missing', kind: 'skill', raw: '---\n' + BASE_SKILL_FM.join('\n') + '\n\n# Body\n' }, + { name: 'malformed-close-fence-four-dashes', kind: 'skill', raw: '---\n' + BASE_SKILL_FM.join('\n') + '\n----\n\n# Body\n' }, + { name: 'empty-fence-pair', kind: 'skill', raw: '---\n---\n\n# Body\n' }, + { name: 'empty-fence-with-blank', kind: 'skill', raw: '---\n\n---\n\n# Body\n' }, + { name: 'missing-frontmatter', kind: 'skill', raw: '# Sample Skill\n\n## Purpose\n\n- Read the pipeline.\n' }, + { name: 'empty-markdown', kind: 'skill', raw: '' }, + { name: 'whitespace-only-markdown', kind: 'skill', raw: ' \n\n\t\n' }, + { name: 'frontmatter-is-a-sequence', kind: 'skill', raw: '---\n- one\n- two\n---\n\n# Body\n' }, + + /* ── Field-level validity ─────────────────────────────────────────────── */ + { name: 'invalid-definition-id-uppercase', kind: 'skill', raw: skillWith(['id: Sample_Skill', 'name: Sample Skill', 'description: A sample.', 'pages:', ' - candidates']) }, + { name: 'invalid-definition-id-leading-dash', kind: 'skill', raw: skillWith(['id: -sample', 'name: Sample Skill', 'description: A sample.', 'pages:', ' - candidates']) }, + { name: 'invalid-definition-id-underscore', kind: 'skill', raw: skillWith(['id: sample_skill', 'name: Sample Skill', 'description: A sample.', 'pages:', ' - candidates']) }, + { name: 'missing-id-derives-from-name', kind: 'skill', raw: skillWith(['name: Sample Skill', 'description: A sample.', 'pages:', ' - candidates']) }, + { name: 'missing-name', kind: 'skill', raw: skillWith(['id: sample-skill', 'description: A sample.', 'pages:', ' - candidates']) }, + { name: 'missing-pages', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: Sample Skill', 'description: A sample.']) }, + { name: 'unknown-page', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: Sample Skill', 'description: A sample.', 'pages:', ' - nowhere']) }, + { name: 'page-alias-uppercase', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: Sample Skill', 'description: A sample.', 'pages:', ' - Candidates']) }, + { name: 'page-alias-underscore', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: Sample Skill', 'description: A sample.', 'pages:', ' - talent_pool']) }, + { name: 'invalid-status-skill', kind: 'skill', raw: skillPlus('status: bogus') }, + { name: 'inactive-status-skill', kind: 'skill', raw: skillPlus('status: inactive') }, + { name: 'invalid-status-agent', kind: 'agent', raw: agentPlus('status: bogus') }, + { name: 'invalid-reasoning-agent', kind: 'agent', raw: agentPlus('reasoning: turbo') }, + { name: 'invalid-icon-agent', kind: 'agent', raw: agentPlus('icon: rocket') }, + { name: 'invalid-version-agent', kind: 'agent', raw: agentPlus('version: zero') }, + { name: 'invalid-field-type-pages-scalar', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: Sample Skill', 'description: A sample.', 'pages: candidates']) }, + { name: 'invalid-field-type-pages-scalar-agent', kind: 'agent', raw: agentWith(['id: sample-agent', 'name: Sample Agent', 'description: A sample.', 'pages: candidates']) }, + { name: 'invalid-field-type-name-list', kind: 'skill', raw: skillWith(['id: sample-skill', 'name:', ' - a', ' - b', 'description: A sample.', 'pages:', ' - candidates']) }, + { name: 'invalid-permissions-access', kind: 'agent', raw: agentWith([...BASE_AGENT_FM, 'permissions:', ' access: nobody']) }, + { name: 'self-subagent', kind: 'agent', raw: agentWith([...BASE_AGENT_FM, 'subagents:', ' - sample-agent']) }, + + /* ── Size ─────────────────────────────────────────────────────────────── */ + { name: 'oversized-markdown', kind: 'skill', raw: skillPlus() + '\n' + 'x'.repeat(65600) }, + { name: 'at-size-bound', kind: 'skill', raw: (() => { const b = skillPlus(); return b + 'y'.repeat(65536 - b.length); })() }, + + /* ── Gaps closed after the first oracle run ───────────────────────────── */ + { name: 'missing-name-agent', kind: 'agent', raw: agentWith(['id: sample-agent', 'description: A sample.', 'pages:', ' - candidates']) }, + { name: 'missing-pages-agent', kind: 'agent', raw: agentWith(['id: sample-agent', 'name: Sample Agent', 'description: A sample.']) }, + { name: 'unknown-page-agent', kind: 'agent', raw: agentWith(['id: sample-agent', 'name: Sample Agent', 'description: A sample.', 'pages:', ' - nowhere']) }, + { name: 'oversized-agent', kind: 'agent', raw: agentPlus() + '\n' + 'x'.repeat(65600) }, + { name: 'visibility-field-personal', kind: 'skill', raw: skillPlus('visibility: personal') }, + { name: 'visibility-field-invalid', kind: 'skill', raw: skillPlus('visibility: nobody') }, + { name: 'blank-page-entry-agent', kind: 'agent', raw: agentWith(['id: sample-agent', 'name: Sample Agent', 'description: A sample.', 'pages:', ' - candidates', ' - ""']) }, + { name: 'duplicate-page-entry-agent', kind: 'agent', raw: agentWith(['id: sample-agent', 'name: Sample Agent', 'description: A sample.', 'pages:', ' - candidates', ' - candidates']) }, + { name: 'id-with-trailing-space', kind: 'skill', raw: skillWith(['id: sample-skill ', 'name: Sample Skill', 'description: A sample.', 'pages:', ' - candidates']) }, + { name: 'id-quoted', kind: 'skill', raw: skillWith(['id: "sample-skill"', 'name: Sample Skill', 'description: A sample.', 'pages:', ' - candidates']) }, + { name: 'starters-plain-strings-agent', kind: 'agent', raw: agentWith([...BASE_AGENT_FM, 'starters:', ' - Who is waiting?', ' - Where are the gaps?']) }, + { name: 'starter-missing-label-agent', kind: 'agent', raw: agentWith([...BASE_AGENT_FM, 'starters:', ' - prompt: Only a prompt']) }, + { name: 'knowledge-note-agent', kind: 'agent', raw: agentWith([...BASE_AGENT_FM, 'knowledge:', ' - label: A note', ' body: The body of the note.']) }, + { name: 'knowledge-bad-kind-agent', kind: 'agent', raw: agentWith([...BASE_AGENT_FM, 'knowledge:', ' - label: A note', ' kind: rumour', ' body: x']) }, + { name: 'knowledge-link-without-url-agent', kind: 'agent', raw: agentWith([...BASE_AGENT_FM, 'knowledge:', ' - label: A link', ' kind: link']) }, + { name: 'skills-scalar-coerced-agent', kind: 'agent', raw: agentPlus('skills: candidate-search') }, + { name: 'skills-blank-entry-agent', kind: 'agent', raw: agentWith([...BASE_AGENT_FM, 'skills:', ' - candidate-search', ' - ""']) }, + { name: 'web-search-snake-case-agent', kind: 'agent', raw: agentPlus('web_search: true') }, + { name: 'web-search-string-true-agent', kind: 'agent', raw: agentPlus('webSearch: "true"') }, + { name: 'version-quoted-integer-agent', kind: 'agent', raw: agentPlus('version: "3"') }, + { name: 'version-zero-agent', kind: 'agent', raw: agentPlus('version: 0') }, + { name: 'version-empty-agent', kind: 'agent', raw: agentPlus('version:') }, + { name: 'crlf-inside-frontmatter-only', kind: 'skill', raw: (() => { const i = SKILL.indexOf('\n---\n', 3); return SKILL.slice(0, i).replace(/\n/g, '\r\n') + SKILL.slice(i); })() }, + { name: 'body-whitespace-only-after-fence', kind: 'skill', raw: '---\n' + BASE_SKILL_FM.join('\n') + '\n---\n \n\t\n' }, + { name: 'description-whitespace-only', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: Sample Skill', 'description: " "', 'pages:', ' - candidates']) }, + { name: 'permissions-null-agent', kind: 'agent', raw: agentPlus('permissions:') }, + { name: 'permissions-people-bad-role-agent', kind: 'agent', raw: agentWith([...BASE_AGENT_FM, 'permissions:', ' access: specific', ' people:', ' - user: a@b.c', ' role: overlord']) }, + + /* ── Gaps closed after the second oracle run ────────────────────────────── + * + * Two rules had no case that could tell a correct implementation from a + * plausible wrong one, so both were being asserted by accident: + * + * The id fallback chain. Every earlier case either declares `id:` or + * declares a `name:` to slug, so the THIRD link — the default path, which + * is the literal `custom` on both sides — was never reached by a case that + * is otherwise valid. `id-omitted-unnamed` is that case: the frontend + * accepts it as the skill `custom`, and a backend deriving no id would + * refuse it on save. + * + * Text coercion. `name`, `description`, `category`, `trigger` and the + * entries of `pages` are text columns in migration 000005, and a definition + * may write any of them as a list, a number or a boolean. The frontend + * keeps the raw value on the record and stringifies it at each use; the + * backend has to choose the string at the boundary. These cases pin which + * string it chooses. + */ + { name: 'id-omitted-unnamed', kind: 'skill', raw: skillWith(['description: A sample.', 'pages:', ' - candidates']) }, + { name: 'id-omitted-named', kind: 'skill', raw: skillWith(['name: Sample Skill', 'description: A sample.', 'pages:', ' - candidates']) }, + { name: 'id-omitted-unnamed-agent', kind: 'agent', raw: agentWith(['description: A sample.', 'pages:', ' - candidates']) }, + { name: 'id-omitted-named-agent', kind: 'agent', raw: agentWith(['name: Sample Agent', 'description: A sample.', 'pages:', ' - candidates']) }, + { name: 'id-omitted-name-unsluggable', kind: 'skill', raw: skillWith(['name: "!!!"', 'description: A sample.', 'pages:', ' - candidates']) }, + { name: 'name-numeric', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: 42', 'description: A sample.', 'pages:', ' - candidates']) }, + { name: 'name-boolean', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: true', 'description: A sample.', 'pages:', ' - candidates']) }, + { name: 'description-list', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: Sample Skill', 'description:', ' - one', ' - two', 'pages:', ' - candidates']) }, + { name: 'description-numeric', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: Sample Skill', 'description: 7', 'pages:', ' - candidates']) }, + { name: 'pages-numeric-entry', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: Sample Skill', 'description: A sample.', 'pages:', ' - candidates', ' - 5']) }, + { name: 'pages-mapping-entry', kind: 'skill', raw: skillWith(['id: sample-skill', 'name: Sample Skill', 'description: A sample.', 'pages:', ' - page: candidates']) }, + { name: 'trigger-list-agent', kind: 'agent', raw: agentPlus('trigger:', ' - one', ' - two') }, + { name: 'name-list-agent', kind: 'agent', raw: agentWith(['id: sample-agent', 'name:', ' - a', ' - b', 'description: A sample.', 'pages:', ' - candidates']) }, + { name: 'category-numeric', kind: 'skill', raw: skillPlus('category: 3') }, + + /* The other backend-only bound. agent_definitions.version is a PostgreSQL + `integer`; the frontend accepts any whole number of 1 or more, so a version + above 2^31-1 is one the editor takes and the database cannot store. It had + no case at all, which left the rule asserted by nobody. */ + { name: 'version-above-int32-agent', kind: 'agent', raw: agentPlus('version: 3000000000') }, + { name: 'version-at-int32-agent', kind: 'agent', raw: agentPlus('version: 2147483647') }, +]; diff --git a/scripts/gen_resources.py b/scripts/gen_resources.py new file mode 100755 index 0000000..9932f4d --- /dev/null +++ b/scripts/gen_resources.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Emit go-api/internal/domain/resources_gen.go from the live PostgreSQL schema. + +Column names, types, enum values and nullability are read out of +information_schema so they can never drift from the migrations. The per-resource +metadata below (path, default sort, default limit, operations, required fields) +comes from docs/api-contract.md and is the only hand-maintained part. + +Usage: make gen-resources +""" +import collections +import json +import os +import subprocess +import sys + +DB = os.environ.get("DATABASE_NAME", "Krow-force") +HOST = os.environ.get("DATABASE_HOST", "127.0.0.1") +PORT = os.environ.get("DATABASE_PORT", "5432") +USER = os.environ.get("DATABASE_USER", "postgres") + + +def q(sql): + out = subprocess.run( + ["psql", "-h", HOST, "-p", PORT, "-U", USER, "-d", DB, "-tAF", "\x1f", "-c", sql], + capture_output=True, text=True) + if out.returncode: + sys.exit(out.stderr) + return [l.split("\x1f") for l in out.stdout.strip().split("\n") if l.strip()] + + +# ops='' means the resource has a table and is seeded, but serves no HTTP +# endpoint (see api-contract.md §2). It still needs a descriptor so the seeder +# can write it. +META = { + 'job_postings': dict(name='JobPosting', path='job-postings', sort='-created_date', limit=100, ops='List|Get|Create|Update', req=['title']), + 'job_applications':dict(name='JobApplication',path='job-applications',sort='-ai_score', limit=200, ops='List|Create|Update|Delete', req=['job_posting_id','applicant_name','email']), + 'ai_interviews': dict(name='AIInterview', path='ai-interviews', sort='-created_date', limit=100, ops='List|Create', req=['application_id','job_posting_id']), + 'staff': dict(name='Staff', path='staff', sort='-created_date', limit=100, ops='List|Create|Update', req=['name','email','hire_date']), + 'worker_profiles': dict(name='WorkerProfile', path='worker-profiles', sort='-krow_score', limit=500, ops='List|Create|Update', req=['full_name','email']), + 'courses': dict(name='Course', path='courses', sort='-created_date', limit=200, ops='List|Get|Create|Update', req=['title'], orgnull=True), + 'learning_paths': dict(name='LearningPath', path='learning-paths', sort='-created_date', limit=100, ops='List', req=['name'], orgnull=True), + 'role_categories': dict(name='RoleCategory', path='role-categories', sort='-created_date', limit=100, ops='List|Create', req=['name']), + 'certifications': dict(name='Certification', path='certifications', sort='-created_date', limit=200, ops='List|Create|Delete', req=['name']), + 'user_activity': dict(name='UserActivity', path='user-activity', sort='-created_date', limit=500, ops='List|Create', req=['event_type']), + 'evidence': dict(name='Evidence', path='evidence', sort='-created_date', limit=200, ops='List|Create|Update', req=['type','worker_email']), + 'assignments': dict(name='Assignment', path='assignments', sort='-created_date', limit=500, ops='List|Create', req=['job_posting_id','worker_email','starts_at']), + 'shift_records': dict(name='ShiftRecord', path='shift-records', sort='-created_date', limit=500, ops='List', req=[]), + 'badges': dict(name='Badge', path='badges', sort='-created_date', limit=200, ops='', req=['name']), +} +ORDER = list(META) + +# Columns the API never accepts from a request body, on every table that has +# them. The row's identity, its tenant and its timestamps are the server's. +READONLY = {'id', 'org_id', 'created_date', 'updated_date', 'legacy_id'} + +# Columns that are server-owned on ONE table only. +# +# These name a *person*, and before Phase 3D a client could set them freely — +# which meant any authorization rule written on top of them could be defeated by +# the same request the rule was meant to constrain. A caller could reassign a +# worker profile to somebody else, or write an audit-log entry attributed to +# anyone in the organization. +# +# They are read-only here and filled in from the authenticated session instead; +# see the Derived rules in internal/domain/policy.go for which value each one +# receives and when. +SERVER_OWNED = { + 'worker_profiles': {'user_id'}, + 'user_activity': {'user_id', 'user_email', 'user_name', 'account_type'}, + 'job_postings': {'created_by'}, +} + + +def kind(dt, udt, enums): + """Classify a column. + + USER-DEFINED covers both enums and extension types: citext reports as + USER-DEFINED too. Enum-ness is decided by whether pg_enum actually has + labels for the type, not by data_type alone — classifying citext as an + enum with no permitted values rejects every email the API is sent. + """ + if udt == 'uuid': return 'KindUUID', 'uuid' + if dt == 'ARRAY': return 'KindTextArray', 'text[]' + if dt == 'jsonb': return 'KindJSON', 'jsonb' + if dt in ('integer', 'smallint'): return 'KindInt', 'int' + if dt == 'bigint': return 'KindInt', 'bigint' + if dt == 'numeric': return 'KindFloat', 'numeric' + if dt == 'boolean': return 'KindBool', 'boolean' + if dt == 'timestamp with time zone': return 'KindTimestamp', 'timestamptz' + if dt == 'date': return 'KindDate', 'date' + if dt == 'USER-DEFINED' and enums.get(udt): + return 'KindEnum', udt + if udt == 'citext': return 'KindString', 'citext' + return 'KindString', 'text' + + +def main(): + cols = q("""SELECT table_name, column_name, data_type, udt_name, is_nullable + FROM information_schema.columns + WHERE table_schema='public' AND table_name<>'schema_migrations' + ORDER BY table_name, ordinal_position""") + enums = collections.defaultdict(list) + for t, v in q("""SELECT t.typname, e.enumlabel FROM pg_type t + JOIN pg_enum e ON e.enumtypid=t.oid + JOIN pg_namespace n ON n.oid=t.typnamespace + WHERE n.nspname='public' ORDER BY t.typname, e.enumsortorder"""): + enums[t].append(v) + + by_table = collections.defaultdict(list) + for t, c, dt, udt, nul in cols: + by_table[t].append((c, dt, udt, nul == 'YES')) + + o = [] + o.append('// Code generated by scripts/gen_resources.py. DO NOT EDIT BY HAND.') + o.append('// Regenerate with: make gen-resources') + o.append('//') + o.append('// Column names, types, enum values and nullability are read out of') + o.append('// information_schema so they cannot drift from the migrations. The') + o.append('// per-resource metadata (path, default sort, default limit, supported') + o.append('// operations, required fields) comes from docs/api-contract.md.') + o.append('') + o.append('package domain') + o.append('') + o.append('// AllResources is every resource the API serves.') + o.append('var AllResources = []*Resource{') + for tbl in ORDER: + m = META[tbl] + if m['ops']: + ops = ' | '.join('Op' + x for x in m['ops'].split('|')) + else: + # No operations means no routes are registered for this resource. + o.append(f'\t// {m["name"]} serves NO endpoint: useBadges has zero consumers and every') + o.append('\t// badge the UI renders comes from worker_profiles.earned_badges. The') + o.append('\t// descriptor exists so the seeder can write the table. api-contract.md §2.') + ops = '0' + o.append('\t{') + o.append(f'\t\tName: {json.dumps(m["name"])}, Path: {json.dumps(m["path"])}, Table: {json.dumps(tbl)},') + o.append(f'\t\tDefaultSort: {json.dumps(m["sort"])}, DefaultLimit: {m["limit"]},') + o.append(f'\t\tOps: {ops},') + if m.get('orgnull'): + o.append('\t\tOrgNullable: true,') + o.append('\t\tColumns: []Column{') + for c, dt, udt, nullable in by_table[tbl]: + k, cast = kind(dt, udt, enums) + p = [f'Name: {json.dumps(c)}', f'Kind: {k}', f'PGType: {json.dumps(cast)}'] + if not nullable: p.append('NotNull: true') + if c in READONLY or c in SERVER_OWNED.get(tbl, ()): + p.append('ReadOnly: true') + if c in m['req']: p.append('Required: true') + if k == 'KindEnum': + p.append('Enum: []string{' + ', '.join(json.dumps(v) for v in enums[udt]) + '}') + o.append('\t\t\t{' + ', '.join(p) + '},') + o.append('\t\t},') + o.append('\t},') + o.append('}') + sys.stdout.write('\n'.join(o) + '\n') + + +if __name__ == '__main__': + main() diff --git a/scripts/oracle.mjs b/scripts/oracle.mjs new file mode 100644 index 0000000..950fbae --- /dev/null +++ b/scripts/oracle.mjs @@ -0,0 +1,187 @@ +/** + * The JS parser, as an oracle. + * + * Runs the REAL frontend module graph through Vite — `import.meta.glob`, the + * `@/` alias and raw Markdown loading behave exactly as they do in the app, the + * same technique `scripts/skill-check.mjs` uses. A mock of the registry would + * reproduce none of the behaviour this file exists to capture. + * + * Emits one JSON document: for every shipped definition and every adversarial + * case, what the JS parser did with it. That document is the fixture the Go + * conformance suite asserts against, so "the Go parser agrees with the JS + * parser" is a comparison against the JS parser's actual output rather than + * against anybody's description of it. + */ +import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +/* The frontend checkout. Overridable so this runs anywhere the two repos are + checked out side by side, which is the layout it defaults to. */ +const FRONTEND = process.env.KROW_FRONTEND + || new URL('../../krow-demo', import.meta.url).pathname; +const { createServer } = await import(join(FRONTEND, 'node_modules/vite/dist/node/index.js')); +const { CASES } = await import(new URL('./cases.mjs', import.meta.url).href); + +const server = await createServer({ + root: FRONTEND, server: { middlewareMode: true }, appType: 'custom', logLevel: 'error', +}); + +const skillReg = await server.ssrLoadModule('/src/lib/skills/registry.js'); +const agentReg = await server.ssrLoadModule('/src/lib/agents/registry.js'); + +/** Every .md under a directory, recursively, repo-relative. */ +function walk(dir) { + const out = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) out.push(...walk(full)); + else if (entry.endsWith('.md')) out.push(full); + } + return out.sort(); +} + +/** The fields the backend contract actually projects out of a definition. */ +const projectSkill = (s) => ({ + id: s.id, + name: s.name, + description: s.description, + status: s.status, + pages: s.pages, + kind: s.kind, + category: s.category, + actions: s.actions, + triggers: s.triggers, + declaredTriggers: s.declaredTriggers, + prompt: s.prompt ?? null, + facets: s.facets, + skillId: s.skillId ?? null, +}); + +const projectAgent = (a) => ({ + id: a.id, + name: a.name, + description: a.description, + status: a.status, + version: a.version, + pages: a.pages, + icon: a.icon, + reasoning: a.reasoning, + trigger: a.trigger, + webSearch: a.webSearch, + skills: a.skills, + subagents: a.subagents, + starters: a.starters, + permissions: a.permissions, + errors: a.errors, +}); + +/** One definition, as the JS side sees it end to end. */ +function observe(raw, kind) { + const out = { kind }; + + /* Layer 1 — the fence. */ + try { + out.hasFrontmatter = skillReg.hasFrontmatter(raw); + const { data, body } = skillReg.parseFrontmatter(raw); + out.frontmatter = { ok: true, data, body }; + } catch (error) { + out.hasFrontmatter = (() => { try { return skillReg.hasFrontmatter(raw); } catch { return null; } })(); + out.frontmatter = { ok: false, error: String(error?.message ?? error) }; + } + + /* Layer 2 — the definition. */ + try { + const parsed = kind === 'agent' + ? agentReg.parseAgent(raw, { custom: true }) + : skillReg.parseSkill(raw, { custom: true }); + out.parse = { ok: true }; + out.normalized = kind === 'agent' ? projectAgent(parsed) : projectSkill(parsed); + out.markdownVerbatim = parsed.markdown === raw; + } catch (error) { + out.parse = { ok: false, error: String(error?.message ?? error) }; + out.normalized = null; + out.markdownVerbatim = null; + } + + /* Layer 3 — the save gate. This is the accept/reject contract. */ + const problem = kind === 'agent' + ? agentReg.validateAgentSource(raw) + : skillReg.validateSkillSource(raw); + out.accepted = problem === null; + out.rejection = problem; + + return out; +} + +/* ── The shipped corpus ───────────────────────────────────────────────────── */ +const corpus = []; +const groups = [ + { dir: join(FRONTEND, 'src/agents'), type: 'agent', kind: 'agent' }, + { dir: join(FRONTEND, 'src/skills'), type: 'skill', kind: 'skill' }, + { dir: join(FRONTEND, 'skill-examples'), type: 'example', kind: 'skill' }, +]; + +for (const { dir, type, kind } of groups) { + for (const file of walk(dir)) { + const raw = readFileSync(file, 'utf8'); + corpus.push({ + path: relative(FRONTEND, file), + type, + rawBase64: Buffer.from(raw, 'utf8').toString('base64'), + bytes: Buffer.byteLength(raw, 'utf8'), + ...observe(raw, kind), + }); + } +} + +/* ── The adversarial cases ────────────────────────────────────────────────── */ +const cases = CASES.map((c) => ({ + name: c.name, + rawBase64: Buffer.from(c.raw, 'utf8').toString('base64'), + bytes: Buffer.byteLength(c.raw, 'utf8'), + ...observe(c.raw, c.kind), +})); + +/* ── The closed vocabularies, read out of the JS tables themselves ────────── */ +const surfaces = await server.ssrLoadModule('/src/lib/skills/surfaces.js'); +const agentVocab = await server.ssrLoadModule('/src/lib/agents/vocabulary.js'); + +const vocabulary = { + pages: surfaces.SKILL_SURFACES.map((s) => ({ id: s.id, aliases: s.aliases || [] })), + agentStatuses: agentVocab.AGENT_STATUSES, + defaultAgentStatus: agentVocab.DEFAULT_AGENT_STATUS, + reasoning: agentVocab.SUPPORTED_REASONING, + defaultReasoning: agentVocab.DEFAULT_REASONING, + icons: agentVocab.AGENT_ICONS, + defaultIcon: agentVocab.DEFAULT_AGENT_ICON, + knowledgeKinds: agentVocab.KNOWLEDGE_KINDS, + defaultKnowledgeKind: agentVocab.DEFAULT_KNOWLEDGE_KIND, + access: agentVocab.AGENT_ACCESS, + defaultAccess: agentVocab.DEFAULT_AGENT_ACCESS, + roles: agentVocab.PERMISSION_ROLES, + defaultRole: agentVocab.DEFAULT_PERMISSION_ROLE, +}; + +await server.close(); + +const doc = { + generatedBy: 'scripts/oracle.mjs against the frontend module graph', + frontendParser: { + yaml: 'src/lib/skills/yaml.js (hand-written YAML subset, no dependency)', + frontmatter: 'src/lib/skills/registry.js — normalizeDefinition / hasFrontmatter / parseFrontmatter', + skill: 'src/lib/skills/registry.js — parseSkill / validateSkillSource', + agent: 'src/lib/agents/registry.js — parseAgent / validateAgentSource', + }, + vocabulary, + corpus, + cases, +}; + +const target = process.argv[2]; +writeFileSync(target, JSON.stringify(doc, null, 2) + '\n'); + +const acc = (xs) => xs.filter((x) => x.accepted).length; +console.log(`corpus ${corpus.length} files — ${acc(corpus)} accepted, ${corpus.length - acc(corpus)} rejected`); +console.log(`cases ${cases.length} — ${acc(cases)} accepted, ${cases.length - acc(cases)} rejected`); +console.log(`markdown verbatim: ${[...corpus, ...cases].every((x) => x.markdownVerbatim !== false)}`); +console.log(`written: ${target}`); diff --git a/scripts/verify_schema.sql b/scripts/verify_schema.sql new file mode 100644 index 0000000..78a37de --- /dev/null +++ b/scripts/verify_schema.sql @@ -0,0 +1,66 @@ +-- Reports what actually exists in the application schema. +-- Read-only. Uses information_schema and pg_indexes, both standard views; +-- no system catalog is written and no object is modified. + +\echo '── Applied migration ─────────────────────────────────────────────────' +SELECT version, dirty FROM schema_migrations; + +\echo '' +\echo '── Tables in the application schema ──────────────────────────────────' +SELECT table_name +FROM information_schema.tables +WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' + AND table_name <> 'schema_migrations' +ORDER BY table_name; + +\echo '' +\echo '── Enum types and their values ───────────────────────────────────────' +SELECT t.typname AS enum_type, + string_agg(e.enumlabel, ', ' ORDER BY e.enumsortorder) AS values +FROM pg_type t +JOIN pg_enum e ON e.enumtypid = t.oid +JOIN pg_namespace n ON n.oid = t.typnamespace +WHERE n.nspname = current_schema() +GROUP BY t.typname +ORDER BY t.typname; + +\echo '' +\echo '── Constraint counts by type ─────────────────────────────────────────' +SELECT CASE contype + WHEN 'p' THEN 'primary key' + WHEN 'f' THEN 'foreign key' + WHEN 'u' THEN 'unique' + WHEN 'c' THEN 'check' + END AS constraint_type, + count(*) AS total +FROM pg_constraint c +JOIN pg_namespace n ON n.oid = c.connamespace +WHERE n.nspname = current_schema() AND contype IN ('p','f','u','c') +GROUP BY contype +ORDER BY contype; + +\echo '' +\echo '── Foreign keys ──────────────────────────────────────────────────────' +SELECT conrelid::regclass::text AS child, + confrelid::regclass::text AS parent, + conname AS constraint_name +FROM pg_constraint c +JOIN pg_namespace n ON n.oid = c.connamespace +WHERE n.nspname = current_schema() AND contype = 'f' +ORDER BY child, parent, conname; + +\echo '' +\echo '── Indexes ───────────────────────────────────────────────────────────' +SELECT tablename, indexname +FROM pg_indexes +WHERE schemaname = current_schema() AND tablename <> 'schema_migrations' +ORDER BY tablename, indexname; + +\echo '' +\echo '── Objects outside the application schema created by this migration ──' +\echo '(expected: zero rows)' +SELECT n.nspname AS schema, c.relname AS object +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE n.nspname NOT IN ('pg_catalog','information_schema','pg_toast', current_schema()) + AND c.relkind IN ('r','i','S'); diff --git a/seed/fixtures/seed.json b/seed/fixtures/seed.json new file mode 100644 index 0000000..b527718 --- /dev/null +++ b/seed/fixtures/seed.json @@ -0,0 +1,4929 @@ +{ + "demoUser": { + "id": "user_demo", + "full_name": "Alex Rivera", + "email": "demo@krow.app", + "role": "admin", + "account_type": "employer", + "created_date": "2026-06-01T09:00:00.000Z", + "preferences": { + "owliverDefault": true, + "compactDensity": false, + "emailDigest": true + } + }, + "entities": { + "JobPosting": [ + { + "id": "job_senior_server", + "company": "Fairmont San Jose", + "custom_requirements": "Fine dining or banquet service at senior level — recover a table before the client notices there was anything to recover.", + "leadership_expectations": "Set the pace for the servers on your floor", + "attendance_expectations": "Available evenings and weekends", + "title": "Senior Server – Fine Dining", + "role_category": "Server", + "description": "An advanced service role for seated fine dining events in San Jose. You carry the standard for your section, recover a table before a client notices there was anything to recover, and set the pace for the servers around you.", + "responsibilities": [ + "Run a section at fine dining standard without supervision", + "Recover service issues before they reach the client", + "Set timing and pace for the servers around you", + "Handle allergens and special requests accurately", + "Support the captain through arrivals, toasts and close" + ], + "qualifications": [ + "4+ years of fine dining or banquet service", + "Verified advanced service capability", + "Comfortable as the senior server on a floor", + "Available evenings and weekends" + ], + "nice_to_haves": [ + "Wine service knowledge", + "Captain experience", + "Bilingual" + ], + "min_experience_years": 4, + "english_required": "fluent", + "certifications_required": [ + "Food Handler Card" + ], + "skill_requirements": [ + { + "skill_id": "server", + "level": "advanced", + "weight": 40 + }, + { + "skill_id": "customer_service", + "level": "intermediate", + "weight": 25 + }, + { + "skill_id": "food_safety", + "level": "beginner", + "weight": 20 + }, + { + "skill_id": "leadership", + "level": "beginner", + "weight": 15 + } + ], + "pay_range_min": 24, + "pay_range_max": 34, + "location": "San Jose, CA", + "status": "active", + "ai_generated": false, + "created_date": "2026-08-08T09:00:00.000Z" + }, + { + "id": "job_bartender", + "company": "Bay Event Staffing", + "custom_requirements": "Own your bar from setup to breakdown at high-volume private events.", + "physical_requirements": "Comfortable standing for a full shift", + "title": "Bartender", + "role_category": "Bartender", + "skill_requirements": [ + { + "skill_id": "bartending", + "level": "intermediate", + "weight": 60 + }, + { + "skill_id": "customer_service", + "level": "beginner", + "weight": 40 + } + ], + "description": "We are building a bench of dependable bartenders for high-volume private events across the region. You will own your bar from setup to breakdown, pour with speed and consistency, and keep guests feeling looked after even when the room is three deep.", + "responsibilities": [ + "Set up and break down a full service bar", + "Pour classic cocktails to spec at event pace", + "Verify guest age and refuse service responsibly", + "Track inventory and flag low stock before service", + "Keep the bar clean and compliant throughout the shift" + ], + "qualifications": [ + "Prior bartending experience at events or in a busy venue", + "Working knowledge of classic cocktail specs", + "Comfortable standing for a full shift", + "Valid alcohol service certification" + ], + "nice_to_haves": [ + "Craft cocktail background", + "Bilingual", + "Own bar kit" + ], + "min_experience_years": 2, + "english_required": "conversational", + "certifications_required": [ + "TIPS Certified" + ], + "pay_range_min": 0, + "pay_range_max": 0, + "location": "", + "status": "active", + "ai_generated": true, + "created_date": "2026-07-30T09:00:00.000Z" + }, + { + "id": "job_server_fine", + "company": "Valley Catering Co.", + "custom_requirements": "Tray service and synchronized coursing experience for seated events.", + "attendance_expectations": "Available evenings and weekends", + "skill_requirements": [ + { + "skill_id": "server", + "level": "advanced", + "weight": 45 + }, + { + "skill_id": "customer_service", + "level": "intermediate", + "weight": 30 + }, + { + "skill_id": "food_safety", + "level": "beginner", + "weight": 25 + } + ], + "title": "Event Server – Fine Dining", + "role_category": "Server", + "description": "Fine dining service for seated corporate dinners and weddings in Silicon Valley. You will run synchronized courses, read a table without hovering, and hold a standard of polish that clients notice and rebook for.", + "responsibilities": [ + "Execute synchronized coursed service for seated events", + "Polish and set glassware, china, and flatware", + "Describe menus and handle allergen questions accurately", + "Clear and reset tables between courses discreetly", + "Support captains during guest arrivals and toasts" + ], + "qualifications": [ + "2+ years of banquet or fine dining service", + "Tray service and synchronized coursing experience", + "Professional appearance and clear communication", + "Available evenings and weekends" + ], + "nice_to_haves": [ + "Wine service knowledge", + "Captain experience", + "Bilingual" + ], + "min_experience_years": 2, + "english_required": "fluent", + "certifications_required": [ + "Food Handler Card" + ], + "pay_range_min": 18, + "pay_range_max": 26, + "location": "Silicon Valley, CA", + "status": "active", + "ai_generated": true, + "created_date": "2026-07-29T09:00:00.000Z" + }, + { + "id": "job_security", + "company": "Legendary Event Staff", + "custom_requirements": "Valid California Guard Card on file before the first shift.", + "physical_requirements": "Able to stand and patrol for extended periods", + "title": "Event Security Officer", + "role_category": "Security", + "description": "Licensed event security for concerts, corporate activations, and private functions across Los Angeles. Presence over confrontation: you set the tone at the door and de-escalate long before anything becomes an incident.", + "responsibilities": [ + "Staff entry points and verify credentials", + "Conduct bag checks per venue policy", + "De-escalate guest conflicts calmly", + "Document incidents accurately and promptly", + "Coordinate with venue staff and local authorities" + ], + "qualifications": [ + "Valid California Guard Card", + "3+ years of event or venue security experience", + "Clear incident reporting skills", + "Able to stand and patrol for extended periods" + ], + "nice_to_haves": [ + "Crowd management training", + "CPR certified", + "Bilingual" + ], + "min_experience_years": 3, + "english_required": "conversational", + "certifications_required": [ + "Guard Card", + "CPR / First Aid" + ], + "pay_range_min": 20, + "pay_range_max": 28, + "location": "Los Angeles, CA", + "status": "paused", + "ai_generated": false, + "created_date": "2026-07-28T09:00:00.000Z" + }, + { + "id": "job_chef", + "company": "Valley Catering Co.", + "custom_requirements": "Offsite and high-volume catering experience, with food cost ownership.", + "leadership_expectations": "Lead and schedule a rotating kitchen brigade", + "skill_requirements": [ + { + "skill_id": "food_safety", + "level": "advanced", + "weight": 40 + }, + { + "skill_id": "leadership", + "level": "advanced", + "weight": 35 + }, + { + "skill_id": "customer_service", + "level": "beginner", + "weight": 25 + } + ], + "title": "Executive Chef – Catering", + "role_category": "Chef", + "description": "Lead the kitchen for a high-end catering operation running multiple events a week. You will design menus, cost them honestly, and run a brigade that holds quality at 400 covers the same way it does at 40.", + "responsibilities": [ + "Design and cost seasonal event menus", + "Lead and schedule a rotating kitchen brigade", + "Own food safety compliance across offsite kitchens", + "Manage vendor relationships and food cost targets", + "Execute tastings and client menu consultations" + ], + "qualifications": [ + "8+ years of professional kitchen experience", + "3+ years leading a brigade at executive or sous level", + "ServSafe certification", + "Offsite and high-volume catering experience" + ], + "nice_to_haves": [ + "Culinary degree", + "Plated dinner specialization", + "Bilingual kitchen leadership" + ], + "min_experience_years": 8, + "english_required": "fluent", + "certifications_required": [ + "ServSafe" + ], + "pay_range_min": 45, + "pay_range_max": 70, + "location": "Bay Area, CA", + "status": "active", + "ai_generated": true, + "created_date": "2026-07-27T09:00:00.000Z" + }, + { + "id": "job_bartender_corp", + "company": "Bay Event Staffing", + "custom_requirements": "Corporate or executive event experience, with batched cocktail programs.", + "leadership_expectations": "Run a bar independently at corporate functions", + "skill_requirements": [ + { + "skill_id": "bartending", + "level": "advanced", + "weight": 50 + }, + { + "skill_id": "customer_service", + "level": "intermediate", + "weight": 30 + }, + { + "skill_id": "leadership", + "level": "beginner", + "weight": 20 + } + ], + "title": "Experienced Bartender – Corporate Events", + "role_category": "Bartender", + "description": "Corporate hospitality bartending for tech campuses and executive functions in the Bay Area. Batch cocktails, clean presentation, and the judgment to run a bar unsupervised in front of an executive audience.", + "responsibilities": [ + "Build and execute batched cocktail programs", + "Run a bar independently at corporate functions", + "Maintain responsible service standards", + "Present a clean, branded bar throughout service", + "Reconcile consumption counts after service" + ], + "qualifications": [ + "5+ years of bartending experience", + "Corporate or executive event experience", + "RBS or equivalent alcohol service certification", + "Reliable transportation across the Bay Area" + ], + "nice_to_haves": [ + "Zero-proof program experience", + "Barista skills", + "Own bar kit" + ], + "min_experience_years": 5, + "english_required": "fluent", + "certifications_required": [ + "RBS Alcohol Server" + ], + "pay_range_min": 22, + "pay_range_max": 32, + "location": "Bay Area, CA", + "status": "active", + "ai_generated": true, + "created_date": "2026-07-26T09:00:00.000Z" + }, + { + "id": "job_picker", + "company": "Oakland Event Logistics", + "custom_requirements": "Accuracy first — every mispick becomes a missing item at an event.", + "physical_requirements": "Able to lift 50 lbs repeatedly", + "attendance_expectations": "Reliable attendance for early shifts", + "title": "Picker", + "role_category": "Picker", + "description": "Warehouse order picking for an event logistics hub in Oakland. Accuracy first, speed second: every mispick becomes a missing item at someone else’s wedding.", + "responsibilities": [ + "Pick and stage orders against pick lists", + "Scan and verify SKUs for accuracy", + "Stage outbound pallets for event trucks", + "Report damaged inventory immediately", + "Keep aisles and staging lanes clear" + ], + "qualifications": [ + "Warehouse or fulfillment experience", + "Able to lift 50 lbs repeatedly", + "Comfortable with handheld scanners", + "Reliable attendance for early shifts" + ], + "nice_to_haves": [ + "Forklift certified", + "Inventory system experience" + ], + "min_experience_years": 1, + "english_required": "basic", + "certifications_required": [ + "Forklift Operator" + ], + "pay_range_min": 40, + "pay_range_max": 45, + "location": "Oakland", + "status": "closed", + "ai_generated": false, + "created_date": "2026-07-20T09:00:00.000Z" + }, + { + "id": "job_banquet", + "company": "Fairmont San Jose", + "custom_requirements": "Hotel banquet background and BEO fluency.", + "leadership_expectations": "Direct a floor team you have often just met that afternoon", + "attendance_expectations": "Available for early mornings and late nights", + "skill_requirements": [ + { + "skill_id": "server", + "level": "advanced", + "weight": 35 + }, + { + "skill_id": "leadership", + "level": "intermediate", + "weight": 35 + }, + { + "skill_id": "customer_service", + "level": "advanced", + "weight": 30 + } + ], + "title": "Banquet Captain – Hotel Events", + "role_category": "Server", + "description": "Captain banquet floors for hotel conferences and galas in San Jose. You are the bridge between the client, the kitchen, and a floor team you have often just met that afternoon.", + "responsibilities": [ + "Lead pre-shift briefings and assign sections", + "Coordinate timing between kitchen and floor", + "Be the client contact throughout the event", + "Resolve service issues without escalation", + "Close out the floor and complete event reports" + ], + "qualifications": [ + "4+ years of banquet service, 1+ in a lead role", + "Proven ability to direct a floor team", + "Strong client-facing communication", + "Available for early mornings and late nights" + ], + "nice_to_haves": [ + "Hotel banquet background", + "BEO fluency", + "Bilingual" + ], + "min_experience_years": 4, + "english_required": "fluent", + "certifications_required": [ + "Food Handler Card" + ], + "pay_range_min": 24, + "pay_range_max": 34, + "location": "San Jose, CA", + "status": "active", + "ai_generated": true, + "created_date": "2026-07-18T09:00:00.000Z" + } + ], + "JobApplication": [ + { + "id": "app_arun", + "applicant_name": "Arun Kumar", + "email": "arun.kumar@example.com", + "phone": "+1-408-555-0210", + "years_experience": 4, + "english_level": "fluent", + "certifications": [ + "Food Handler Card" + ], + "availability": [ + "Weekdays", + "Weekends", + "Evenings" + ], + "skills": [ + "Coursed service", + "Guest recovery", + "Allergen handling" + ], + "companies_worked": [ + "Valley Catering Co." + ], + "client_rating": 4.7, + "professional_summary": "Four years of event service in San Jose, working towards the senior server role on a fine dining floor.", + "selfie_url": "https://i.pravatar.cc/240?img=68", + "job_posting_id": "job_senior_server", + "job_title": "Senior Server – Fine Dining", + "status": "applied", + "ai_score": 0, + "created_date": "2026-08-13T09:00:00.000Z", + "updated_date": "2026-08-13T09:00:00.000Z" + }, + { + "id": "app_priya_nair", + "applicant_name": "Priya Nair", + "email": "priya.nair@example.com", + "phone": "+1-408-555-0211", + "years_experience": 2, + "english_level": "fluent", + "certifications": [ + "Food Handler Card" + ], + "availability": [ + "Weekends", + "Evenings" + ], + "skills": [ + "Table service", + "Guest relations" + ], + "companies_worked": [ + "Bay Event Staffing" + ], + "client_rating": 4.5, + "professional_summary": "Two years of event service, moving towards fine dining sections.", + "selfie_url": "https://i.pravatar.cc/240?img=47", + "job_posting_id": "job_senior_server", + "job_title": "Senior Server – Fine Dining", + "status": "applied", + "ai_score": 0, + "created_date": "2026-08-12T09:00:00.000Z", + "updated_date": "2026-08-12T09:00:00.000Z" + }, + { + "id": "app_antoine", + "applicant_name": "Chef Antoine Dubois", + "email": "antoine.dubois@email.com", + "phone": "+1-415-555-0101", + "years_experience": 12, + "english_level": "fluent", + "certifications": [ + "ServSafe", + "CPR / First Aid" + ], + "availability": [ + "Weekdays", + "Weekends", + "Evenings" + ], + "skills": [ + "Menu design", + "Brigade leadership", + "Food costing", + "Plated dinners", + "Offsite catering" + ], + "companies_worked": [], + "client_rating": 4.9, + "professional_summary": "Executive chef with 12 years across hotel banquets and high-end offsite catering. Has run brigades of 20+ and holds plate standards at 400 covers.", + "selfie_url": "https://i.pravatar.cc/240?img=45", + "job_posting_id": "job_chef", + "job_title": "Executive Chef – Catering", + "status": "hired", + "ai_score": 97, + "ai_match_label": "Excellent Match", + "ai_summary": "Exceptional fit. Twelve years of executive kitchen leadership with direct offsite catering experience and current ServSafe certification. Availability covers the full event calendar.", + "ai_strengths": [ + "12 years executive chef experience", + "ServSafe certified", + "Proven brigade leadership at high volume", + "Full-calendar availability" + ], + "ai_gaps": [ + "No formal culinary degree on file" + ], + "ai_recommendation": "Shortlisted", + "score_breakdown": { + "experience": 99, + "english": 95, + "reliability": 96, + "certifications": 98, + "availability": 94, + "personality": 91, + "culture_fit": 93, + "communication_style": 92, + "attendance_expectations": 95, + "physical_requirements": 90, + "leadership_expectations": 97, + "job_related_answers": 96, + "verified_skills": 95, + "scenario_judgment": 94, + "employer_requirements": 98 + }, + "created_date": "2026-07-24T09:00:00.000Z", + "updated_date": "2026-07-25T09:00:00.000Z" + }, + { + "status": "applied", + "ai_score": 0, + "certifications": [], + "availability": [], + "skills": [], + "companies_worked": [], + "client_rating": 0, + "created_date": "2026-08-04T09:00:00.000Z", + "updated_date": "2026-08-04T09:00:00.000Z", + "id": "app_priya", + "applicant_name": "Priya Raman", + "email": "priya.raman@email.com", + "phone": "+1-415-555-0110", + "years_experience": 6, + "english_level": "fluent", + "professional_summary": "Sous chef moving toward executive roles in catering.", + "job_posting_id": "job_chef", + "job_title": "Executive Chef – Catering" + }, + { + "id": "app_marcus", + "applicant_name": "Marcus Williams", + "email": "marcus.w@email.com", + "phone": "+1-415-555-0102", + "years_experience": 5, + "english_level": "fluent", + "certifications": [ + "Guard Card", + "CPR / First Aid" + ], + "availability": [ + "Weekends", + "Evenings", + "Overnight" + ], + "skills": [ + "Crowd management", + "De-escalation", + "Incident reporting", + "Access control" + ], + "companies_worked": [], + "client_rating": 4.8, + "professional_summary": "Licensed event security officer with five years across concerts and corporate activations. Known for defusing situations before they escalate.", + "selfie_url": "https://i.pravatar.cc/240?img=12", + "job_posting_id": "job_security", + "job_title": "Event Security Officer", + "status": "hired", + "ai_score": 94, + "ai_match_label": "Excellent Match", + "ai_summary": "Strong fit. Current Guard Card and CPR certification with five years of directly relevant event security work and overnight availability.", + "ai_strengths": [ + "Current Guard Card", + "Five years event security", + "Documented de-escalation record" + ], + "ai_gaps": [ + "No formal crowd management course on file" + ], + "ai_recommendation": "Shortlisted", + "score_breakdown": { + "experience": 92, + "english": 93, + "reliability": 95, + "certifications": 97, + "availability": 96, + "personality": 90, + "culture_fit": 91, + "communication_style": 89, + "attendance_expectations": 96, + "physical_requirements": 95, + "leadership_expectations": 86, + "job_related_answers": 93, + "verified_skills": 92, + "scenario_judgment": 95, + "employer_requirements": 94 + }, + "created_date": "2026-07-24T09:00:00.000Z", + "updated_date": "2026-07-25T09:00:00.000Z" + }, + { + "id": "app_dana", + "applicant_name": "Dana Whitfield", + "email": "dana.whitfield@email.com", + "phone": "+1-415-555-0111", + "years_experience": 4, + "english_level": "fluent", + "certifications": [ + "Guard Card" + ], + "availability": [ + "Weekends", + "Evenings" + ], + "skills": [ + "Access control", + "Incident reporting" + ], + "companies_worked": [], + "client_rating": 0, + "professional_summary": "Venue security officer transitioning into private event work.", + "job_posting_id": "job_security", + "job_title": "Event Security Officer", + "status": "interview", + "ai_score": 0, + "interview_id": "int_dana", + "created_date": "2026-08-01T09:00:00.000Z", + "updated_date": "2026-08-03T09:00:00.000Z" + }, + { + "status": "applied", + "ai_score": 0, + "certifications": [], + "availability": [], + "skills": [], + "companies_worked": [], + "client_rating": 0, + "created_date": "2026-08-04T09:00:00.000Z", + "updated_date": "2026-08-04T09:00:00.000Z", + "id": "app_omar", + "applicant_name": "Omar Haddad", + "email": "omar.haddad@email.com", + "phone": "+1-415-555-0112", + "years_experience": 2, + "english_level": "conversational", + "job_posting_id": "job_security", + "job_title": "Event Security Officer" + }, + { + "status": "applied", + "ai_score": 0, + "certifications": [], + "availability": [], + "skills": [], + "companies_worked": [], + "client_rating": 0, + "created_date": "2026-08-04T09:00:00.000Z", + "updated_date": "2026-08-04T09:00:00.000Z", + "id": "app_kevin", + "applicant_name": "Kevin Boyle", + "email": "kevin.boyle@email.com", + "phone": "+1-415-555-0113", + "years_experience": 7, + "english_level": "native", + "job_posting_id": "job_security", + "job_title": "Event Security Officer" + }, + { + "status": "applied", + "ai_score": 0, + "certifications": [], + "availability": [], + "skills": [], + "companies_worked": [], + "client_rating": 0, + "created_date": "2026-08-04T09:00:00.000Z", + "updated_date": "2026-08-04T09:00:00.000Z", + "id": "app_rosa", + "applicant_name": "Rosa Delgado", + "email": "rosa.delgado@email.com", + "phone": "+1-415-555-0114", + "years_experience": 3, + "english_level": "fluent", + "job_posting_id": "job_security", + "job_title": "Event Security Officer" + }, + { + "status": "applied", + "ai_score": 0, + "certifications": [], + "availability": [], + "skills": [], + "companies_worked": [], + "client_rating": 0, + "created_date": "2026-08-04T09:00:00.000Z", + "updated_date": "2026-08-04T09:00:00.000Z", + "id": "app_tyler", + "applicant_name": "Tyler Nunez", + "email": "tyler.nunez@email.com", + "phone": "+1-415-555-0115", + "years_experience": 1, + "english_level": "conversational", + "job_posting_id": "job_security", + "job_title": "Event Security Officer" + }, + { + "id": "app_marco", + "applicant_name": "Marco Rivera", + "email": "marco.rivera@email.com", + "phone": "+1-415-555-0103", + "years_experience": 6, + "english_level": "fluent", + "certifications": [ + "RBS Alcohol Server", + "TIPS Certified" + ], + "availability": [ + "Weekdays", + "Evenings" + ], + "skills": [ + "Batch cocktails", + "Craft cocktails", + "Bar setup", + "Inventory control" + ], + "companies_worked": [], + "client_rating": 4.7, + "professional_summary": "Corporate event bartender with six years on Bay Area tech campuses. Builds batched programs that hold quality at volume.", + "selfie_url": "https://i.pravatar.cc/240?img=33", + "job_posting_id": "job_bartender_corp", + "job_title": "Experienced Bartender – Corporate Events", + "status": "hired", + "ai_score": 92, + "ai_match_label": "Excellent Match", + "ai_summary": "Excellent fit for corporate hospitality. Six years of directly comparable work, both required certifications current, and reliable Bay Area coverage.", + "ai_strengths": [ + "Six years corporate bartending", + "RBS and TIPS certified", + "Batch cocktail program experience" + ], + "ai_gaps": [ + "Limited zero-proof program experience" + ], + "ai_recommendation": "Shortlisted", + "score_breakdown": { + "experience": 94, + "english": 92, + "reliability": 93, + "certifications": 96, + "availability": 88, + "personality": 90, + "culture_fit": 92, + "communication_style": 91, + "attendance_expectations": 92, + "physical_requirements": 89, + "leadership_expectations": 82, + "job_related_answers": 94, + "verified_skills": 93, + "scenario_judgment": 90, + "employer_requirements": 93 + }, + "created_date": "2026-07-24T09:00:00.000Z", + "updated_date": "2026-07-25T09:00:00.000Z" + }, + { + "id": "app_sofia", + "applicant_name": "Sofia Mendez", + "email": "sofia.mendez@email.com", + "phone": "+1-415-555-0104", + "years_experience": 4, + "english_level": "fluent", + "certifications": [ + "Food Handler Card" + ], + "availability": [ + "Weekends", + "Evenings" + ], + "skills": [ + "Synchronized service", + "Tray service", + "Wine service", + "Guest relations" + ], + "companies_worked": [], + "client_rating": 4.6, + "professional_summary": "Fine dining event server with four years of coursed banquet service and wine knowledge.", + "selfie_url": "https://i.pravatar.cc/240?img=47", + "job_posting_id": "job_server_fine", + "job_title": "Event Server – Fine Dining", + "status": "ai_screened", + "ai_score": 89, + "ai_match_label": "Excellent Match", + "ai_summary": "Strong fine dining fit. Four years of coursed service with wine knowledge that exceeds the posting, and weekend availability aligned to the event calendar.", + "ai_strengths": [ + "Four years fine dining service", + "Wine service knowledge", + "Strong guest relations" + ], + "ai_gaps": [ + "No captain-level experience yet" + ], + "ai_recommendation": "Interview", + "score_breakdown": { + "experience": 88, + "english": 92, + "reliability": 90, + "certifications": 85, + "availability": 87, + "personality": 91, + "culture_fit": 90, + "communication_style": 93, + "attendance_expectations": 89, + "physical_requirements": 86, + "leadership_expectations": 74, + "job_related_answers": 90, + "verified_skills": 88, + "scenario_judgment": 87, + "employer_requirements": 89 + }, + "created_date": "2026-08-02T09:00:00.000Z", + "updated_date": "2026-08-05T09:00:00.000Z" + }, + { + "id": "app_tanya", + "applicant_name": "Tanya Cruz", + "email": "tanya.cruz@email.com", + "phone": "+1-415-555-0105", + "years_experience": 3, + "english_level": "fluent", + "certifications": [ + "Food Handler Card" + ], + "availability": [ + "Weekends" + ], + "skills": [ + "Banquet service", + "Tray service", + "Table resets" + ], + "companies_worked": [], + "client_rating": 4.4, + "professional_summary": "Banquet server with three years of seated dinner service.", + "selfie_url": "https://i.pravatar.cc/240?img=44", + "job_posting_id": "job_server_fine", + "job_title": "Event Server – Fine Dining", + "status": "interview", + "ai_score": 83, + "ai_match_label": "Excellent Match", + "ai_summary": "Solid fit with room to grow. Three years of banquet service meets the requirement; weekend-only availability is the main constraint.", + "ai_strengths": [ + "Three years banquet service", + "Reliable weekend availability", + "Clean service technique" + ], + "ai_gaps": [ + "Weekend-only availability", + "No wine service training" + ], + "ai_recommendation": "Interview", + "interview_id": "int_tanya", + "score_breakdown": { + "experience": 80, + "english": 90, + "reliability": 88, + "certifications": 82, + "availability": 70, + "personality": 88, + "culture_fit": 86, + "communication_style": 87, + "attendance_expectations": 90, + "physical_requirements": 85, + "leadership_expectations": 68, + "job_related_answers": 84, + "verified_skills": 81, + "scenario_judgment": 82, + "employer_requirements": 83 + }, + "created_date": "2026-08-01T09:00:00.000Z", + "updated_date": "2026-08-05T09:00:00.000Z" + }, + { + "id": "app_nadia", + "applicant_name": "Nadia Petrova", + "email": "nadia.petrova@email.com", + "phone": "+1-415-555-0116", + "years_experience": 2, + "english_level": "conversational", + "certifications": [], + "availability": [ + "Weekends", + "Evenings" + ], + "skills": [ + "Table service" + ], + "companies_worked": [], + "client_rating": 0, + "professional_summary": "Restaurant server moving into event work.", + "job_posting_id": "job_server_fine", + "job_title": "Event Server – Fine Dining", + "status": "ai_screened", + "ai_score": 0, + "created_date": "2026-08-03T09:00:00.000Z", + "updated_date": "2026-08-04T09:00:00.000Z" + }, + { + "id": "app_jordan", + "applicant_name": "Jordan Blake", + "email": "jordan.blake@email.com", + "phone": "+1-415-555-0106", + "years_experience": 3, + "english_level": "fluent", + "certifications": [ + "TIPS Certified" + ], + "availability": [ + "Weekends", + "Evenings" + ], + "skills": [ + "Classic cocktails", + "Bar setup", + "Speed pouring" + ], + "companies_worked": [], + "client_rating": 4.2, + "professional_summary": "Event bartender with three years of high-volume weekend service.", + "job_posting_id": "job_bartender", + "job_title": "Bartender", + "status": "interview", + "ai_score": 72, + "ai_match_label": "Good Match", + "ai_summary": "Meets the core requirements. Three years of high-volume experience with current TIPS certification; cocktail range is narrower than ideal.", + "ai_strengths": [ + "TIPS certified", + "High-volume service experience" + ], + "ai_gaps": [ + "Limited classic cocktail range", + "No craft background" + ], + "ai_recommendation": "Interview", + "interview_id": "int_jordan", + "score_breakdown": { + "experience": 74, + "english": 85, + "reliability": 78, + "certifications": 80, + "availability": 82, + "personality": 76, + "culture_fit": 74, + "communication_style": 78, + "attendance_expectations": 80, + "physical_requirements": 82, + "leadership_expectations": 58, + "job_related_answers": 70, + "verified_skills": 68, + "scenario_judgment": 72, + "employer_requirements": 73 + }, + "created_date": "2026-08-01T09:00:00.000Z", + "updated_date": "2026-08-05T09:00:00.000Z" + }, + { + "id": "app_elena", + "applicant_name": "Elena Vasquez", + "email": "elena.vasquez@email.com", + "phone": "+1-415-555-0107", + "years_experience": 2, + "english_level": "conversational", + "certifications": [ + "TIPS Certified" + ], + "availability": [ + "Evenings" + ], + "skills": [ + "Bar setup", + "Wine service" + ], + "companies_worked": [], + "client_rating": 4, + "professional_summary": "Barback stepping up to bartending at private events.", + "job_posting_id": "job_bartender", + "job_title": "Bartender", + "status": "interview", + "ai_score": 68, + "ai_match_label": "Good Match", + "ai_summary": "Meets the minimum bar. Two years of experience with certification in place; availability and cocktail depth are the open questions.", + "ai_strengths": [ + "TIPS certified", + "Strong bar setup discipline" + ], + "ai_gaps": [ + "Evenings-only availability", + "Limited independent bartending time" + ], + "ai_recommendation": "Interview", + "interview_id": "int_elena", + "score_breakdown": { + "experience": 62, + "english": 72, + "reliability": 76, + "certifications": 80, + "availability": 58, + "personality": 78, + "culture_fit": 72, + "communication_style": 70, + "attendance_expectations": 78, + "physical_requirements": 80, + "leadership_expectations": 52, + "job_related_answers": 64, + "verified_skills": 62, + "scenario_judgment": 66, + "employer_requirements": 68 + }, + "created_date": "2026-08-02T09:00:00.000Z", + "updated_date": "2026-08-05T09:00:00.000Z" + }, + { + "id": "app_sam", + "applicant_name": "Sam Okafor", + "email": "sam.okafor@email.com", + "phone": "+1-415-555-0108", + "years_experience": 0, + "english_level": "basic", + "certifications": [], + "availability": [ + "Weekends" + ], + "skills": [], + "companies_worked": [], + "client_rating": 0, + "professional_summary": "Looking for a first hospitality role.", + "job_posting_id": "job_bartender", + "job_title": "Bartender", + "status": "ai_screened", + "ai_score": 28, + "ai_match_label": "Not a Fit", + "ai_summary": "Below the requirement for this posting. No bartending experience or alcohol service certification, and availability is limited to weekends.", + "ai_strengths": [ + "Motivated and available weekends" + ], + "ai_gaps": [ + "No bartending experience", + "No alcohol service certification", + "Basic English only" + ], + "ai_recommendation": "Reject", + "score_breakdown": { + "experience": 10, + "english": 35, + "reliability": 45, + "certifications": 0, + "availability": 50, + "personality": 60, + "culture_fit": 48, + "communication_style": 38, + "attendance_expectations": 55, + "physical_requirements": 70, + "leadership_expectations": 20, + "job_related_answers": 18, + "verified_skills": 12, + "scenario_judgment": 25, + "employer_requirements": 22 + }, + "created_date": "2026-08-03T09:00:00.000Z", + "updated_date": "2026-08-04T09:00:00.000Z" + }, + { + "id": "app_hector", + "applicant_name": "Hector Lima", + "email": "hector.lima@email.com", + "phone": "+1-415-555-0109", + "years_experience": 2, + "english_level": "conversational", + "certifications": [ + "Forklift Operator" + ], + "availability": [ + "Weekdays", + "Early mornings" + ], + "skills": [ + "Order picking", + "Scanner operation", + "Pallet staging" + ], + "companies_worked": [], + "client_rating": 3.9, + "professional_summary": "Warehouse picker with forklift certification.", + "job_posting_id": "job_picker", + "job_title": "Picker", + "status": "ai_screened", + "ai_score": 61, + "ai_match_label": "Good Match", + "ai_summary": "Adequate fit. Forklift certified with two years of picking experience; accuracy history is not yet verified.", + "ai_strengths": [ + "Forklift certified", + "Early morning availability" + ], + "ai_gaps": [ + "Unverified pick accuracy", + "Limited scanner system exposure" + ], + "ai_recommendation": "Maybe", + "score_breakdown": { + "experience": 60, + "english": 65, + "reliability": 68, + "certifications": 85, + "availability": 78, + "personality": 70, + "culture_fit": 64, + "communication_style": 58, + "attendance_expectations": 72, + "physical_requirements": 88, + "leadership_expectations": 40, + "job_related_answers": 55, + "verified_skills": 52, + "scenario_judgment": 58, + "employer_requirements": 62 + }, + "created_date": "2026-07-30T09:00:00.000Z", + "updated_date": "2026-08-01T09:00:00.000Z" + }, + { + "id": "app_wei", + "applicant_name": "Wei Chen", + "email": "wei.chen@email.com", + "phone": "+1-415-555-0117", + "years_experience": 1, + "english_level": "basic", + "certifications": [], + "availability": [ + "Weekdays" + ], + "skills": [ + "Order picking" + ], + "companies_worked": [], + "client_rating": 0, + "professional_summary": "Fulfillment associate seeking steady warehouse shifts.", + "job_posting_id": "job_picker", + "job_title": "Picker", + "status": "ai_screened", + "ai_score": 0, + "created_date": "2026-07-31T09:00:00.000Z", + "updated_date": "2026-08-01T09:00:00.000Z" + }, + { + "status": "applied", + "ai_score": 0, + "certifications": [], + "availability": [], + "skills": [], + "companies_worked": [], + "client_rating": 0, + "created_date": "2026-08-04T09:00:00.000Z", + "updated_date": "2026-08-04T09:00:00.000Z", + "id": "app_grace", + "applicant_name": "Grace Mwangi", + "email": "grace.mwangi@email.com", + "phone": "+1-415-555-0118", + "years_experience": 3, + "english_level": "fluent", + "job_posting_id": "job_picker", + "job_title": "Picker" + }, + { + "id": "app_ana", + "applicant_name": "Ana Ruiz", + "email": "ana.ruiz@email.com", + "phone": "+1-415-555-0119", + "years_experience": 5, + "english_level": "fluent", + "certifications": [ + "Food Handler Card" + ], + "availability": [ + "Weekdays", + "Weekends" + ], + "skills": [ + "Banquet leadership", + "BEO reading", + "Floor coordination" + ], + "companies_worked": [], + "client_rating": 0, + "professional_summary": "Banquet server with lead-shift experience in hotel events.", + "job_posting_id": "job_banquet", + "job_title": "Banquet Captain – Hotel Events", + "status": "ai_screened", + "ai_score": 0, + "created_date": "2026-08-02T09:00:00.000Z", + "updated_date": "2026-08-03T09:00:00.000Z" + }, + { + "id": "app_devon", + "applicant_name": "Devon Carter", + "email": "devon.carter@email.com", + "phone": "+1-415-555-0120", + "years_experience": 6, + "english_level": "native", + "certifications": [ + "Food Handler Card" + ], + "availability": [ + "Weekdays", + "Weekends", + "Evenings" + ], + "skills": [ + "Floor leadership", + "Client relations", + "Event reporting" + ], + "companies_worked": [], + "client_rating": 0, + "professional_summary": "Hotel banquet captain with six years directing event floors.", + "job_posting_id": "job_banquet", + "job_title": "Banquet Captain – Hotel Events", + "status": "interview", + "ai_score": 0, + "interview_id": "int_devon", + "created_date": "2026-08-01T09:00:00.000Z", + "updated_date": "2026-08-04T09:00:00.000Z" + }, + { + "status": "applied", + "ai_score": 0, + "certifications": [], + "availability": [], + "skills": [], + "companies_worked": [], + "client_rating": 0, + "created_date": "2026-08-04T09:00:00.000Z", + "updated_date": "2026-08-04T09:00:00.000Z", + "id": "app_lucas", + "applicant_name": "Lucas Moreau", + "email": "lucas.moreau@email.com", + "phone": "+1-415-555-0121", + "years_experience": 4, + "english_level": "fluent", + "job_posting_id": "job_banquet", + "job_title": "Banquet Captain – Hotel Events" + }, + { + "status": "applied", + "ai_score": 0, + "certifications": [], + "availability": [], + "skills": [], + "companies_worked": [], + "client_rating": 0, + "created_date": "2026-08-04T09:00:00.000Z", + "updated_date": "2026-08-04T09:00:00.000Z", + "id": "app_fatima", + "applicant_name": "Fatima Nasser", + "email": "fatima.nasser@email.com", + "phone": "+1-415-555-0122", + "years_experience": 2, + "english_level": "fluent", + "job_posting_id": "job_banquet", + "job_title": "Banquet Captain – Hotel Events" + } + ], + "AIInterview": [ + { + "id": "int_tanya", + "application_id": "app_tanya", + "job_posting_id": "job_server_fine", + "job_title": "Event Server – Fine Dining", + "candidate_name": "Tanya Cruz", + "messages": [ + { + "role": "assistant", + "content": "Hi Tanya — thanks for making time. Tell me about the busiest banquet shift you've worked and how you handled it.", + "timestamp": "2026-08-04T09:00:00.000Z" + }, + { + "role": "user", + "content": "We were short two people on a 300-guest event. I re-sectioned the floor, moved the strongest server to the head tables, and pulled the rest into a runner rotation so nothing sat under the lamps.", + "timestamp": "2026-08-04T09:00:00.000Z", + "response_time_seconds": 11 + }, + { + "role": "assistant", + "content": "What broke first, and what did you do the moment you noticed?", + "timestamp": "2026-08-04T09:00:00.000Z" + }, + { + "role": "user", + "content": "Dessert timing. I saw the pass backing up, so I pulled coffee service forward by five minutes to buy the kitchen room instead of letting plates die.", + "timestamp": "2026-08-04T09:00:00.000Z", + "response_time_seconds": 9 + } + ], + "overall_interview_score": 72, + "verdict": "maybe", + "hire_recommendation": "Worth a trial shift on a coursed dinner before committing to the season.", + "integrity_score": 100, + "ai_flags": [], + "category_scores": { + "communication": 78, + "confidence": 71, + "experience_relevance": 74, + "culture_fit": 76, + "problem_solving": 69, + "personality": 77, + "communication_style": 76, + "attendance_expectations": 82, + "reliability": 80, + "physical_requirements": 84, + "leadership_expectations": 62, + "scenario_judgment": 70, + "job_related_answers": 73, + "verified_skills": 68 + }, + "strengths": [ + "Clear, calm delivery under pressure", + "Reprioritized service timing sensibly" + ], + "concerns": [ + "Weekend-only availability", + "Limited wine service vocabulary" + ], + "best_fit_roles": [ + "Event Server", + "Banquet Server" + ], + "summary": "Composed and specific about a real understaffed service. Judgment is sound; depth on fine dining vocabulary is still developing.", + "reasoning": "Answers referenced concrete decisions rather than generalities, which supports the reliability signal. Scores held back by narrow availability and thin wine knowledge.", + "created_date": "2026-08-05T09:00:00.000Z" + }, + { + "id": "int_jordan", + "application_id": "app_jordan", + "job_posting_id": "job_bartender", + "job_title": "Bartender", + "candidate_name": "Jordan Blake", + "messages": [ + { + "role": "assistant", + "content": "Hi Jordan — thanks for making time. Tell me about the busiest bar shift you've worked and how you handled it.", + "timestamp": "2026-08-04T09:00:00.000Z" + }, + { + "role": "user", + "content": "We were short two people on a 300-guest event. I re-sectioned the floor, moved the strongest server to the head tables, and pulled the rest into a runner rotation so nothing sat under the lamps.", + "timestamp": "2026-08-04T09:00:00.000Z", + "response_time_seconds": 11 + }, + { + "role": "assistant", + "content": "What broke first, and what did you do the moment you noticed?", + "timestamp": "2026-08-04T09:00:00.000Z" + }, + { + "role": "user", + "content": "Dessert timing. I saw the pass backing up, so I pulled coffee service forward by five minutes to buy the kitchen room instead of letting plates die.", + "timestamp": "2026-08-04T09:00:00.000Z", + "response_time_seconds": 9 + } + ], + "overall_interview_score": 0, + "verdict": "maybe", + "hire_recommendation": "", + "integrity_score": 100, + "ai_flags": [], + "category_scores": {}, + "strengths": [], + "concerns": [], + "best_fit_roles": [], + "summary": "", + "reasoning": "", + "created_date": "2026-08-05T09:00:00.000Z" + }, + { + "id": "int_elena", + "application_id": "app_elena", + "job_posting_id": "job_bartender", + "job_title": "Bartender", + "candidate_name": "Elena Vasquez", + "messages": [ + { + "role": "assistant", + "content": "Hi Elena — thanks for making time. Tell me about the busiest bar shift you've worked and how you handled it.", + "timestamp": "2026-08-04T09:00:00.000Z" + }, + { + "role": "user", + "content": "We were short two people on a 300-guest event. I re-sectioned the floor, moved the strongest server to the head tables, and pulled the rest into a runner rotation so nothing sat under the lamps.", + "timestamp": "2026-08-04T09:00:00.000Z", + "response_time_seconds": 11 + }, + { + "role": "assistant", + "content": "What broke first, and what did you do the moment you noticed?", + "timestamp": "2026-08-04T09:00:00.000Z" + }, + { + "role": "user", + "content": "Dessert timing. I saw the pass backing up, so I pulled coffee service forward by five minutes to buy the kitchen room instead of letting plates die.", + "timestamp": "2026-08-04T09:00:00.000Z", + "response_time_seconds": 9 + } + ], + "overall_interview_score": 0, + "verdict": "maybe", + "hire_recommendation": "", + "integrity_score": 100, + "ai_flags": [], + "category_scores": {}, + "strengths": [], + "concerns": [], + "best_fit_roles": [], + "summary": "", + "reasoning": "", + "created_date": "2026-08-05T09:00:00.000Z" + }, + { + "id": "int_dana", + "application_id": "app_dana", + "job_posting_id": "job_security", + "job_title": "Event Security Officer", + "candidate_name": "Dana Whitfield", + "messages": [ + { + "role": "assistant", + "content": "Hi Dana — thanks for making time. Tell me about the busiest door shift you've worked and how you handled it.", + "timestamp": "2026-08-04T09:00:00.000Z" + }, + { + "role": "user", + "content": "We were short two people on a 300-guest event. I re-sectioned the floor, moved the strongest server to the head tables, and pulled the rest into a runner rotation so nothing sat under the lamps.", + "timestamp": "2026-08-04T09:00:00.000Z", + "response_time_seconds": 11 + }, + { + "role": "assistant", + "content": "What broke first, and what did you do the moment you noticed?", + "timestamp": "2026-08-04T09:00:00.000Z" + }, + { + "role": "user", + "content": "Dessert timing. I saw the pass backing up, so I pulled coffee service forward by five minutes to buy the kitchen room instead of letting plates die.", + "timestamp": "2026-08-04T09:00:00.000Z", + "response_time_seconds": 9 + } + ], + "overall_interview_score": 0, + "verdict": "maybe", + "hire_recommendation": "", + "integrity_score": 100, + "ai_flags": [], + "category_scores": {}, + "strengths": [], + "concerns": [], + "best_fit_roles": [], + "summary": "", + "reasoning": "", + "created_date": "2026-08-03T09:00:00.000Z" + } + ], + "Staff": [ + { + "id": "staff_marco", + "name": "Marco Rivera", + "email": "marco.rivera@email.com", + "phone": "+1-415-555-0103", + "role": "Experienced Bartender – Corporate Events", + "profile_tier": "Skilled", + "hire_date": "2026-07-25", + "application_id": "app_marco", + "job_posting_id": "job_bartender_corp", + "ai_score": 92, + "status": "onboarding", + "client_rating": 0, + "endorsed_skills": [], + "created_date": "2026-07-25T09:00:00.000Z" + }, + { + "id": "staff_marcus", + "name": "Marcus Williams", + "email": "marcus.w@email.com", + "phone": "+1-415-555-0102", + "role": "Event Security Officer", + "profile_tier": "Skilled", + "hire_date": "2026-07-25", + "application_id": "app_marcus", + "job_posting_id": "job_security", + "ai_score": 94, + "status": "onboarding", + "client_rating": 0, + "endorsed_skills": [], + "created_date": "2026-07-25T09:00:00.000Z" + }, + { + "id": "staff_antoine", + "name": "Chef Antoine Dubois", + "email": "antoine.dubois@email.com", + "phone": "+1-415-555-0101", + "role": "Executive Chef – Catering", + "profile_tier": "Skilled", + "hire_date": "2026-07-25", + "application_id": "app_antoine", + "job_posting_id": "job_chef", + "ai_score": 97, + "status": "onboarding", + "client_rating": 0, + "endorsed_skills": [], + "created_date": "2026-07-25T09:00:00.000Z" + } + ], + "WorkerProfile": [ + { + "id": "wp_maria", + "full_name": "Maria Gonzalez", + "email": "maria.gonzalez@example.com", + "phone": "+1-415-555-0201", + "address": "San Jose, CA", + "selfie_url": "https://i.pravatar.cc/240?img=31", + "languages": [ + "English", + "Spanish" + ], + "availability": [ + "Weekdays", + "Weekends", + "Evenings" + ], + "transportation": "Own vehicle", + "certifications": [ + "Food Handler Card", + "ServSafe" + ], + "experience": [ + { + "company": "Legendary Event Staff", + "role": "Banquet Captain", + "years": 4 + }, + { + "company": "Fairmont San Jose", + "role": "Event Server", + "years": 3 + } + ], + "experience_years": 7, + "current_position": "Banquet Captain", + "desired_position": "Event Lead", + "career_goals": "Run event operations for a hotel group within three years.", + "skills": [ + "Floor leadership", + "Coursed service", + "Client relations", + "BEO reading", + "Scheduling" + ], + "industries": [ + "Hospitality", + "Events" + ], + "personality": "Steady and direct. Sets expectations early and holds them without friction.", + "strengths": [ + "Calm under pressure", + "Trains new staff well", + "Client-facing polish" + ], + "weaknesses": [ + "Takes on too much rather than delegating" + ], + "communication_style": "Concise and warm; confirms understanding before moving on.", + "salary_expectations": "$30–$36/hr", + "leadership_potential": 92, + "ai_interview_score": 93, + "krow_score": 94, + "reliability_score": 95, + "profile_completion": 100, + "xp": 1480, + "completed_courses": [ + { + "course_id": "mod_server_server_fundamentals", + "title": "Server Fundamentals", + "score": 93, + "completed_date": "2026-07-14T09:00:00.000Z" + }, + { + "course_id": "mod_server_workplace_safety", + "title": "Workplace Safety", + "score": 93, + "completed_date": "2026-07-14T09:00:00.000Z" + }, + { + "course_id": "mod_server_basic_guest_service", + "title": "Basic Guest Service", + "score": 93, + "completed_date": "2026-07-14T09:00:00.000Z" + }, + { + "course_id": "mod_server_advanced_guest_service", + "title": "Advanced Guest Service", + "score": 93, + "completed_date": "2026-07-14T09:00:00.000Z" + }, + { + "course_id": "mod_server_table_management", + "title": "Table Management", + "score": 93, + "completed_date": "2026-07-14T09:00:00.000Z" + }, + { + "course_id": "mod_server_order_accuracy", + "title": "Order Accuracy", + "score": 93, + "completed_date": "2026-07-14T09:00:00.000Z" + }, + { + "course_id": "course_service_basics", + "title": "Fine Dining Service Standards", + "score": 93, + "completed_date": "2026-07-14T09:00:00.000Z" + }, + { + "course_id": "mod_server_guest_recovery", + "title": "Guest Recovery", + "score": 93, + "completed_date": "2026-07-14T09:00:00.000Z" + }, + { + "course_id": "mod_server_high_volume_service_operations", + "title": "High-Volume Service Operations", + "score": 93, + "completed_date": "2026-07-14T09:00:00.000Z" + }, + { + "course_id": "mod_customer_service_guest_first_impressions", + "title": "Guest First Impressions", + "score": 93, + "completed_date": "2026-07-14T09:00:00.000Z" + }, + { + "course_id": "mod_customer_service_reading_the_room", + "title": "Reading the Room", + "score": 93, + "completed_date": "2026-07-14T09:00:00.000Z" + }, + { + "course_id": "mod_customer_service_handling_complaints", + "title": "Handling Complaints", + "score": 93, + "completed_date": "2026-07-14T09:00:00.000Z" + }, + { + "course_id": "mod_customer_service_service_recovery_basics", + "title": "Service Recovery Basics", + "score": 93, + "completed_date": "2026-07-14T09:00:00.000Z" + }, + { + "course_id": "mod_customer_service_vip_high_stakes_guests", + "title": "VIP & High-Stakes Guests", + "score": 93, + "completed_date": "2026-07-14T09:00:00.000Z" + }, + { + "course_id": "mod_customer_service_client_relations", + "title": "Client Relations", + "score": 93, + "completed_date": "2026-07-14T09:00:00.000Z" + }, + { + "course_id": "course_kitchen_safety", + "title": "Kitchen Safety & Hazard Spotting", + "score": 93, + "completed_date": "2026-07-14T09:00:00.000Z" + }, + { + "course_id": "mod_food_safety_personal_hygiene_standards", + "title": "Personal Hygiene Standards", + "score": 93, + "completed_date": "2026-07-14T09:00:00.000Z" + }, + { + "course_id": "mod_leadership_shift_communication", + "title": "Shift Communication", + "score": 93, + "completed_date": "2026-07-14T09:00:00.000Z" + }, + { + "course_id": "mod_leadership_giving_taking_direction", + "title": "Giving & Taking Direction", + "score": 93, + "completed_date": "2026-07-14T09:00:00.000Z" + }, + { + "course_id": "mod_leadership_pre_shift_briefings", + "title": "Pre-Shift Briefings", + "score": 93, + "completed_date": "2026-07-14T09:00:00.000Z" + }, + { + "course_id": "mod_leadership_handling_conflict_on_the_floor", + "title": "Handling Conflict on the Floor", + "score": 93, + "completed_date": "2026-07-14T09:00:00.000Z" + }, + { + "course_id": "course_leadership", + "title": "Leading a Floor Team", + "score": 93, + "completed_date": "2026-07-14T09:00:00.000Z" + }, + { + "course_id": "mod_leadership_section_assignment_coverage", + "title": "Section Assignment & Coverage", + "score": 93, + "completed_date": "2026-07-14T09:00:00.000Z" + } + ], + "earned_badges": [ + { + "name": "Service Fundamentals", + "level": "bronze", + "course_id": "course_service_basics", + "earned_date": "2026-06-22T09:00:00.000Z" + }, + { + "name": "Safety Aware", + "level": "bronze", + "course_id": "course_kitchen_safety", + "earned_date": "2026-07-01T09:00:00.000Z" + }, + { + "name": "Floor Leader", + "level": "gold", + "course_id": "course_leadership", + "earned_date": "2026-07-14T09:00:00.000Z" + } + ], + "capabilities": [ + { + "skill": "Coursed Table Service", + "level": "Verified", + "evidence_id": "ev_maria_service", + "status": "verified", + "verified_date": "2026-06-22T09:00:00.000Z" + }, + { + "skill": "Team Leadership", + "level": "Verified", + "evidence_id": "ev_maria_leadership", + "status": "verified", + "verified_date": "2026-07-14T09:00:00.000Z" + } + ], + "shifts_completed": 64, + "attendance_score": 98, + "performance_score": 93, + "client_rating": 4.9, + "supervisor_rating": 4.8, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z" + }, + { + "id": "wp_iliana", + "full_name": "Iliana Ortega", + "email": "iortega@legendaryeventstaff.com", + "phone": "+1-415-555-0202", + "address": "Oakland, CA", + "selfie_url": "https://i.pravatar.cc/240?img=26", + "languages": [ + "English", + "Spanish" + ], + "availability": [ + "Weekends" + ], + "transportation": "Public transit", + "certifications": [ + "Food Handler Card" + ], + "experience": [ + { + "company": "Legendary Event Staff", + "role": "Event Server", + "years": 1 + } + ], + "experience_years": 1, + "current_position": "Event Server", + "desired_position": "Bartender", + "career_goals": "Move behind the bar and get certified this year.", + "skills": [ + "Table service", + "Guest relations" + ], + "industries": [ + "Hospitality" + ], + "leadership_potential": 24, + "ai_interview_score": 0, + "krow_score": 12, + "reliability_score": 38, + "profile_completion": 62, + "xp": 120, + "completed_courses": [ + { + "course_id": "mod_server_server_fundamentals", + "title": "Server Fundamentals", + "score": 79, + "completed_date": "2026-07-28T09:00:00.000Z" + }, + { + "course_id": "mod_server_workplace_safety", + "title": "Workplace Safety", + "score": 79, + "completed_date": "2026-07-28T09:00:00.000Z" + }, + { + "course_id": "mod_server_basic_guest_service", + "title": "Basic Guest Service", + "score": 79, + "completed_date": "2026-07-28T09:00:00.000Z" + }, + { + "course_id": "mod_server_advanced_guest_service", + "title": "Advanced Guest Service", + "score": 79, + "completed_date": "2026-07-28T09:00:00.000Z" + }, + { + "course_id": "mod_customer_service_guest_first_impressions", + "title": "Guest First Impressions", + "score": 79, + "completed_date": "2026-07-28T09:00:00.000Z" + }, + { + "course_id": "mod_customer_service_reading_the_room", + "title": "Reading the Room", + "score": 79, + "completed_date": "2026-07-28T09:00:00.000Z" + } + ], + "earned_badges": [ + { + "name": "Service Fundamentals", + "level": "bronze", + "course_id": "course_service_basics", + "earned_date": "2026-07-28T09:00:00.000Z" + } + ], + "capabilities": [], + "shifts_completed": 6, + "attendance_score": 92, + "performance_score": 0, + "client_rating": 0, + "supervisor_rating": 0, + "status": "active", + "created_date": "2026-07-22T09:00:00.000Z" + }, + { + "id": "wp_arun", + "full_name": "Arun Kumar", + "email": "arun.kumar@example.com", + "phone": "+1-408-555-0210", + "address": "San Jose, CA", + "selfie_url": "https://i.pravatar.cc/240?img=68", + "languages": [ + "English", + "Tamil" + ], + "availability": [ + "Weekdays", + "Weekends", + "Evenings" + ], + "transportation": "Own vehicle", + "certifications": [ + "Food Handler Card" + ], + "experience": [ + { + "company": "Valley Catering Co.", + "role": "Event Server", + "years": 4 + } + ], + "experience_years": 4, + "current_position": "Event Server", + "desired_position": "Senior Server", + "career_goals": "Run a fine dining section as the senior server on the floor.", + "skills": [ + "Coursed service", + "Guest recovery", + "Allergen handling" + ], + "industries": [ + "Hospitality", + "Events" + ], + "personality": "Unflappable. Notices the table nobody else is watching.", + "strengths": [ + "Calm at volume", + "Accurate under pressure" + ], + "weaknesses": [ + "Quiet in briefings" + ], + "communication_style": "Measured; confirms detail before acting.", + "salary_expectations": "$26–$32/hr", + "leadership_potential": 68, + "ai_interview_score": 86, + "krow_score": 82, + "reliability_score": 91, + "profile_completion": 94, + "xp": 1180, + "completed_courses": [ + { + "course_id": "mod_server_server_fundamentals", + "title": "Server Fundamentals", + "score": 89, + "completed_date": "2026-08-02T09:00:00.000Z" + }, + { + "course_id": "mod_server_workplace_safety", + "title": "Workplace Safety", + "score": 89, + "completed_date": "2026-08-02T09:00:00.000Z" + }, + { + "course_id": "mod_server_basic_guest_service", + "title": "Basic Guest Service", + "score": 89, + "completed_date": "2026-08-02T09:00:00.000Z" + }, + { + "course_id": "mod_server_advanced_guest_service", + "title": "Advanced Guest Service", + "score": 89, + "completed_date": "2026-08-02T09:00:00.000Z" + }, + { + "course_id": "mod_server_table_management", + "title": "Table Management", + "score": 89, + "completed_date": "2026-08-02T09:00:00.000Z" + }, + { + "course_id": "mod_server_order_accuracy", + "title": "Order Accuracy", + "score": 89, + "completed_date": "2026-08-02T09:00:00.000Z" + }, + { + "course_id": "mod_server_guest_recovery", + "title": "Guest Recovery", + "score": 89, + "completed_date": "2026-08-02T09:00:00.000Z" + }, + { + "course_id": "mod_server_high_volume_service_operations", + "title": "High-Volume Service Operations", + "score": 89, + "completed_date": "2026-08-02T09:00:00.000Z" + }, + { + "course_id": "mod_customer_service_guest_first_impressions", + "title": "Guest First Impressions", + "score": 89, + "completed_date": "2026-08-02T09:00:00.000Z" + }, + { + "course_id": "mod_customer_service_reading_the_room", + "title": "Reading the Room", + "score": 89, + "completed_date": "2026-08-02T09:00:00.000Z" + }, + { + "course_id": "mod_customer_service_handling_complaints", + "title": "Handling Complaints", + "score": 89, + "completed_date": "2026-08-02T09:00:00.000Z" + }, + { + "course_id": "mod_customer_service_service_recovery_basics", + "title": "Service Recovery Basics", + "score": 89, + "completed_date": "2026-08-02T09:00:00.000Z" + }, + { + "course_id": "course_kitchen_safety", + "title": "Kitchen Safety & Hazard Spotting", + "score": 89, + "completed_date": "2026-08-02T09:00:00.000Z" + }, + { + "course_id": "mod_food_safety_personal_hygiene_standards", + "title": "Personal Hygiene Standards", + "score": 89, + "completed_date": "2026-08-02T09:00:00.000Z" + }, + { + "course_id": "mod_leadership_shift_communication", + "title": "Shift Communication", + "score": 89, + "completed_date": "2026-08-02T09:00:00.000Z" + }, + { + "course_id": "mod_leadership_giving_taking_direction", + "title": "Giving & Taking Direction", + "score": 89, + "completed_date": "2026-08-02T09:00:00.000Z" + } + ], + "earned_badges": [], + "capabilities": [], + "shifts_completed": 38, + "attendance_score": 96, + "performance_score": 88, + "client_rating": 4.7, + "supervisor_rating": 4.6, + "status": "active", + "created_date": "2026-07-05T09:00:00.000Z" + }, + { + "id": "wp_priya", + "full_name": "Priya Nair", + "email": "priya.nair@example.com", + "phone": "+1-408-555-0211", + "address": "Santa Clara, CA", + "selfie_url": "https://i.pravatar.cc/240?img=47", + "languages": [ + "English", + "Hindi" + ], + "availability": [ + "Weekends", + "Evenings" + ], + "transportation": "Own vehicle", + "certifications": [ + "Food Handler Card" + ], + "experience": [ + { + "company": "Bay Event Staffing", + "role": "Event Server", + "years": 2 + } + ], + "experience_years": 2, + "current_position": "Event Server", + "desired_position": "Senior Server", + "career_goals": "Move up to fine dining sections.", + "skills": [ + "Table service", + "Guest relations" + ], + "industries": [ + "Hospitality" + ], + "leadership_potential": 45, + "ai_interview_score": 74, + "krow_score": 68, + "reliability_score": 84, + "profile_completion": 80, + "xp": 760, + "completed_courses": [ + { + "course_id": "mod_server_server_fundamentals", + "title": "Server Fundamentals", + "score": 85, + "completed_date": "2026-07-30T09:00:00.000Z" + }, + { + "course_id": "mod_server_workplace_safety", + "title": "Workplace Safety", + "score": 85, + "completed_date": "2026-07-30T09:00:00.000Z" + }, + { + "course_id": "mod_server_basic_guest_service", + "title": "Basic Guest Service", + "score": 85, + "completed_date": "2026-07-30T09:00:00.000Z" + }, + { + "course_id": "mod_server_advanced_guest_service", + "title": "Advanced Guest Service", + "score": 85, + "completed_date": "2026-07-30T09:00:00.000Z" + }, + { + "course_id": "mod_server_table_management", + "title": "Table Management", + "score": 85, + "completed_date": "2026-07-30T09:00:00.000Z" + }, + { + "course_id": "mod_server_order_accuracy", + "title": "Order Accuracy", + "score": 85, + "completed_date": "2026-07-30T09:00:00.000Z" + }, + { + "course_id": "mod_customer_service_guest_first_impressions", + "title": "Guest First Impressions", + "score": 85, + "completed_date": "2026-07-30T09:00:00.000Z" + }, + { + "course_id": "mod_customer_service_reading_the_room", + "title": "Reading the Room", + "score": 85, + "completed_date": "2026-07-30T09:00:00.000Z" + }, + { + "course_id": "mod_customer_service_handling_complaints", + "title": "Handling Complaints", + "score": 85, + "completed_date": "2026-07-30T09:00:00.000Z" + }, + { + "course_id": "mod_customer_service_service_recovery_basics", + "title": "Service Recovery Basics", + "score": 85, + "completed_date": "2026-07-30T09:00:00.000Z" + }, + { + "course_id": "course_kitchen_safety", + "title": "Kitchen Safety & Hazard Spotting", + "score": 85, + "completed_date": "2026-07-30T09:00:00.000Z" + }, + { + "course_id": "mod_food_safety_personal_hygiene_standards", + "title": "Personal Hygiene Standards", + "score": 85, + "completed_date": "2026-07-30T09:00:00.000Z" + } + ], + "earned_badges": [], + "capabilities": [], + "shifts_completed": 21, + "attendance_score": 93, + "performance_score": 81, + "client_rating": 4.5, + "supervisor_rating": 4.4, + "status": "active", + "created_date": "2026-07-12T09:00:00.000Z" + }, + { + "id": "wp_rahul", + "full_name": "Rahul Menon", + "email": "rahul.menon@example.com", + "phone": "+1-408-555-0212", + "address": "Milpitas, CA", + "selfie_url": "https://i.pravatar.cc/240?img=59", + "languages": [ + "English" + ], + "availability": [ + "Weekends" + ], + "transportation": "Public transit", + "certifications": [], + "experience": [], + "experience_years": 0, + "current_position": "", + "desired_position": "Event Server", + "career_goals": "Get on a floor and learn the work properly.", + "skills": [], + "industries": [ + "Hospitality" + ], + "leadership_potential": 20, + "ai_interview_score": 0, + "krow_score": 41, + "reliability_score": 72, + "profile_completion": 58, + "xp": 320, + "completed_courses": [ + { + "course_id": "mod_server_server_fundamentals", + "title": "Server Fundamentals", + "score": 76, + "completed_date": "2026-08-05T09:00:00.000Z" + }, + { + "course_id": "mod_server_workplace_safety", + "title": "Workplace Safety", + "score": 76, + "completed_date": "2026-08-05T09:00:00.000Z" + }, + { + "course_id": "mod_server_basic_guest_service", + "title": "Basic Guest Service", + "score": 76, + "completed_date": "2026-08-05T09:00:00.000Z" + }, + { + "course_id": "mod_customer_service_guest_first_impressions", + "title": "Guest First Impressions", + "score": 76, + "completed_date": "2026-08-05T09:00:00.000Z" + }, + { + "course_id": "mod_customer_service_reading_the_room", + "title": "Reading the Room", + "score": 76, + "completed_date": "2026-08-05T09:00:00.000Z" + }, + { + "course_id": "course_kitchen_safety", + "title": "Kitchen Safety & Hazard Spotting", + "score": 76, + "completed_date": "2026-08-05T09:00:00.000Z" + }, + { + "course_id": "mod_food_safety_personal_hygiene_standards", + "title": "Personal Hygiene Standards", + "score": 76, + "completed_date": "2026-08-05T09:00:00.000Z" + }, + { + "course_id": "mod_leadership_shift_communication", + "title": "Shift Communication", + "score": 76, + "completed_date": "2026-08-05T09:00:00.000Z" + }, + { + "course_id": "mod_leadership_giving_taking_direction", + "title": "Giving & Taking Direction", + "score": 76, + "completed_date": "2026-08-05T09:00:00.000Z" + } + ], + "earned_badges": [], + "capabilities": [], + "shifts_completed": 4, + "attendance_score": 100, + "performance_score": 0, + "client_rating": 0, + "supervisor_rating": 0, + "status": "active", + "created_date": "2026-07-28T09:00:00.000Z" + }, + { + "id": "wp_aravind", + "full_name": "Aravind Kumar", + "email": "aravind@tenext.in", + "phone": "+1-415-555-0203", + "address": "Fremont, CA", + "selfie_url": "https://i.pravatar.cc/240?img=15", + "languages": [ + "English", + "Tamil", + "Hindi" + ], + "availability": [ + "Weekdays", + "Evenings" + ], + "transportation": "Own vehicle", + "certifications": [], + "experience": [], + "experience_years": 0, + "current_position": "", + "desired_position": "Barback", + "career_goals": "Get a first hospitality shift and build from there.", + "skills": [], + "industries": [ + "Hospitality" + ], + "leadership_potential": 0, + "ai_interview_score": 0, + "krow_score": 0, + "reliability_score": 0, + "profile_completion": 30, + "xp": 0, + "completed_courses": [], + "earned_badges": [], + "capabilities": [], + "shifts_completed": 0, + "attendance_score": 100, + "performance_score": 0, + "client_rating": 0, + "supervisor_rating": 0, + "status": "active", + "created_date": "2026-08-02T09:00:00.000Z" + }, + { + "id": "wp_demo", + "full_name": "New Talent Demo", + "email": "new.demo2@example.com", + "phone": "+1-415-555-0204", + "address": "San Francisco, CA", + "selfie_url": "https://i.pravatar.cc/240?img=9", + "languages": [ + "English" + ], + "availability": [], + "transportation": "", + "certifications": [], + "experience": [], + "experience_years": 0, + "current_position": "", + "desired_position": "", + "career_goals": "", + "skills": [], + "industries": [], + "leadership_potential": 0, + "ai_interview_score": 0, + "krow_score": 0, + "reliability_score": 0, + "profile_completion": 10, + "xp": 0, + "completed_courses": [], + "earned_badges": [], + "capabilities": [], + "shifts_completed": 0, + "attendance_score": 100, + "performance_score": 0, + "client_rating": 0, + "supervisor_rating": 0, + "status": "active", + "created_date": "2026-08-05T09:00:00.000Z" + }, + { + "id": "wp_jamal", + "full_name": "Jamal Whitaker", + "email": "jamal.whitaker@example.com", + "phone": "+1-415-555-0205", + "address": "Berkeley, CA", + "selfie_url": "https://i.pravatar.cc/240?img=52", + "languages": [ + "English" + ], + "availability": [ + "Weekends", + "Evenings" + ], + "transportation": "Own vehicle", + "certifications": [], + "experience": [], + "experience_years": 0, + "current_position": "", + "desired_position": "Event Server", + "career_goals": "Work weekends around school.", + "skills": [], + "industries": [ + "Events" + ], + "leadership_potential": 0, + "ai_interview_score": 0, + "krow_score": 0, + "reliability_score": 0, + "profile_completion": 25, + "xp": 0, + "completed_courses": [], + "earned_badges": [], + "capabilities": [], + "shifts_completed": 0, + "attendance_score": 100, + "performance_score": 0, + "client_rating": 0, + "supervisor_rating": 0, + "status": "active", + "created_date": "2026-08-06T09:00:00.000Z" + }, + { + "id": "wp_lin", + "full_name": "Lin Zhao", + "email": "lin.zhao@example.com", + "phone": "+1-415-555-0206", + "address": "Daly City, CA", + "selfie_url": "https://i.pravatar.cc/240?img=20", + "languages": [ + "English", + "Mandarin" + ], + "availability": [ + "Weekdays" + ], + "transportation": "Public transit", + "certifications": [], + "experience": [], + "experience_years": 0, + "current_position": "", + "desired_position": "Picker", + "career_goals": "Steady weekday warehouse work.", + "skills": [], + "industries": [ + "Logistics" + ], + "leadership_potential": 0, + "ai_interview_score": 0, + "krow_score": 0, + "reliability_score": 0, + "profile_completion": 20, + "xp": 0, + "completed_courses": [], + "earned_badges": [], + "capabilities": [], + "shifts_completed": 0, + "attendance_score": 100, + "performance_score": 0, + "client_rating": 0, + "supervisor_rating": 0, + "status": "active", + "created_date": "2026-08-06T09:00:00.000Z" + } + ], + "Course": [ + { + "id": "course_service_basics", + "title": "Fine Dining Service Standards", + "description": "Coursed service, tray discipline, and the small signals that separate a polished floor from a busy one.", + "category": "Service", + "difficulty": "beginner", + "xp": 120, + "badge_reward": "Service Fundamentals", + "estimated_minutes": 18, + "proof_skill": "Coursed Table Service", + "skill_id": "server", + "target_level": "advanced", + "required_level": "intermediate", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Acknowledges without excuses", + "Offers a concrete remedy", + "Protects the table experience" + ], + "challenge": { + "type": "roleplay", + "prompt": "A guest at a seated dinner says their entrée is cold, and the kitchen is nine minutes behind. Handle it out loud, start to finish.", + "ai_persona": "A polite but disappointed guest at a wedding head table.", + "rubric": [ + { + "criterion": "Acknowledges without excuses", + "weight": 30 + }, + { + "criterion": "Offers a concrete remedy", + "weight": 40 + }, + { + "criterion": "Protects the table experience", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [ + { + "question": "A guest flags an allergy mid-service. What happens first?", + "options": [ + "Guess from the menu", + "Stop the plate and check with the kitchen", + "Serve it anyway", + "Ask another server" + ], + "correct_index": 1 + }, + { + "question": "When do you clear a course?", + "options": [ + "As each guest finishes", + "When the whole table has finished", + "Before dessert only", + "At the captain’s call" + ], + "correct_index": 1 + } + ], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-10T09:00:00.000Z" + }, + { + "id": "course_responsible_service", + "title": "Responsible Alcohol Service", + "description": "Reading intoxication, refusing service without a scene, and documenting what happened.", + "category": "Bar", + "difficulty": "intermediate", + "xp": 160, + "badge_reward": "Responsible Service", + "estimated_minutes": 22, + "proof_skill": "Responsible Service Judgment", + "skill_id": "bartending", + "target_level": "intermediate", + "required_level": "beginner", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Refuses clearly and kindly", + "Offers an alternative", + "Escalates appropriately" + ], + "challenge": { + "type": "roleplay", + "prompt": "A guest who has clearly had enough asks for one more round, and their colleagues are watching. Talk it through.", + "ai_persona": "A friendly but insistent guest at a corporate holiday party.", + "rubric": [ + { + "criterion": "Refuses clearly and kindly", + "weight": 40 + }, + { + "criterion": "Offers an alternative", + "weight": 30 + }, + { + "criterion": "Escalates appropriately", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [ + { + "question": "A guest shows clear signs of intoxication. What do you do?", + "options": [ + "Serve a weaker drink", + "Refuse service and notify your lead", + "Ignore it", + "Ask a coworker to serve them" + ], + "correct_index": 1 + } + ], + "pass_score": 75, + "status": "active", + "created_date": "2026-06-12T09:00:00.000Z" + }, + { + "id": "course_kitchen_safety", + "title": "Kitchen Safety & Hazard Spotting", + "description": "Find the hazard before it finds you — temperature, cross-contamination, and traffic flow.", + "category": "Kitchen", + "difficulty": "beginner", + "xp": 100, + "badge_reward": "Safety Aware", + "estimated_minutes": 14, + "proof_skill": "Hazard Identification", + "skill_id": "food_safety", + "target_level": "beginner", + "required_level": null, + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Identifies temperature violations", + "Spots cross-contamination risk", + "Notes obstruction hazards" + ], + "challenge": { + "type": "photo_identify", + "prompt": "Mark every food-safety hazard you can find in this prep station.", + "image_url": "", + "rubric": [ + { + "criterion": "Identifies temperature violations", + "weight": 40 + }, + { + "criterion": "Spots cross-contamination risk", + "weight": 40 + }, + { + "criterion": "Notes obstruction hazards", + "weight": 20 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [ + { + "question": "What is the danger zone for cold-held food?", + "options": [ + "Below 32°F", + "41°F to 135°F", + "Above 165°F", + "Any temperature" + ], + "correct_index": 1 + } + ], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-14T09:00:00.000Z" + }, + { + "id": "course_leadership", + "title": "Leading a Floor Team", + "description": "Pre-shift briefings, section assignments, and correcting a teammate without deflating them.", + "category": "Leadership", + "difficulty": "advanced", + "xp": 240, + "badge_reward": "Floor Leader", + "estimated_minutes": 30, + "proof_skill": "Team Leadership", + "skill_id": "leadership", + "target_level": "advanced", + "required_level": "intermediate", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Assigns clear ownership", + "Communicates timing", + "Sets a tone people follow" + ], + "challenge": { + "type": "video", + "prompt": "Record the pre-shift briefing you would give a floor team of eight before a 300-guest gala.", + "rubric": [ + { + "criterion": "Assigns clear ownership", + "weight": 35 + }, + { + "criterion": "Communicates timing", + "weight": 35 + }, + { + "criterion": "Sets a tone people follow", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 5, + "min_reliability": 70, + "required_badges": [ + "Service Fundamentals" + ] + }, + "quiz": [], + "pass_score": 80, + "status": "active", + "created_date": "2026-06-16T09:00:00.000Z" + }, + { + "id": "mod_server_server_fundamentals", + "title": "Server Fundamentals", + "description": "Sequence of service, tray discipline, and the first ninety seconds at a table.", + "category": "Service", + "difficulty": "beginner", + "xp": 100, + "estimated_minutes": 18, + "proof_skill": "Server Fundamentals", + "skill_id": "server", + "target_level": "beginner", + "required_level": null, + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Greets and sets expectations", + "Follows the service sequence", + "Handles the tray safely" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a server fundamentals scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "Greets and sets expectations", + "weight": 40 + }, + { + "criterion": "Follows the service sequence", + "weight": 30 + }, + { + "criterion": "Handles the tray safely", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 0 + }, + { + "id": "mod_server_workplace_safety", + "title": "Workplace Safety", + "description": "Spot the hazard before it becomes an incident — spills, traffic, hot pass.", + "category": "Service", + "difficulty": "beginner", + "xp": 100, + "estimated_minutes": 14, + "proof_skill": "Workplace Safety", + "skill_id": "server", + "target_level": "beginner", + "required_level": null, + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Identifies floor hazards", + "Spots obstruction risks", + "Knows when to stop service" + ], + "challenge": { + "type": "photo_identify", + "prompt": "Mark everything a workplace safety check should catch in this photo.", + "rubric": [ + { + "criterion": "Identifies floor hazards", + "weight": 40 + }, + { + "criterion": "Spots obstruction risks", + "weight": 30 + }, + { + "criterion": "Knows when to stop service", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 1 + }, + { + "id": "mod_server_basic_guest_service", + "title": "Basic Guest Service", + "description": "Reading a table, timing an approach, and answering what you actually know.", + "category": "Service", + "difficulty": "beginner", + "xp": 100, + "estimated_minutes": 16, + "proof_skill": "Basic Guest Service", + "skill_id": "server", + "target_level": "beginner", + "required_level": null, + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Reads the table before approaching", + "Answers accurately", + "Closes the interaction cleanly" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a basic guest service scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "Reads the table before approaching", + "weight": 40 + }, + { + "criterion": "Answers accurately", + "weight": 30 + }, + { + "criterion": "Closes the interaction cleanly", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 2 + }, + { + "id": "mod_server_advanced_guest_service", + "title": "Advanced Guest Service", + "description": "Difficult tables, competing demands, and keeping a section moving anyway.", + "category": "Service", + "difficulty": "intermediate", + "xp": 150, + "estimated_minutes": 20, + "proof_skill": "Advanced Guest Service", + "skill_id": "server", + "target_level": "intermediate", + "required_level": "beginner", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Prioritises across tables", + "Keeps guests informed", + "Recovers timing without panic" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a advanced guest service scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "Prioritises across tables", + "weight": 40 + }, + { + "criterion": "Keeps guests informed", + "weight": 30 + }, + { + "criterion": "Recovers timing without panic", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 3 + }, + { + "id": "mod_server_table_management", + "title": "Table Management", + "description": "Running a section: turns, holds, and the order you touch tables in.", + "category": "Service", + "difficulty": "intermediate", + "xp": 150, + "estimated_minutes": 18, + "proof_skill": "Table Management", + "skill_id": "server", + "target_level": "intermediate", + "required_level": "beginner", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Sequences the section", + "Manages holds and turns", + "Communicates with the pass" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a table management scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "Sequences the section", + "weight": 40 + }, + { + "criterion": "Manages holds and turns", + "weight": 30 + }, + { + "criterion": "Communicates with the pass", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 4 + }, + { + "id": "mod_server_order_accuracy", + "title": "Order Accuracy", + "description": "Modifiers, allergens, and repeating back what you are about to send.", + "category": "Service", + "difficulty": "intermediate", + "xp": 150, + "estimated_minutes": 15, + "proof_skill": "Order Accuracy", + "skill_id": "server", + "target_level": "intermediate", + "required_level": "beginner", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Confirms modifiers", + "Flags allergens correctly", + "Verifies before firing" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a order accuracy scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "Confirms modifiers", + "weight": 40 + }, + { + "criterion": "Flags allergens correctly", + "weight": 30 + }, + { + "criterion": "Verifies before firing", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 5 + }, + { + "id": "mod_server_guest_recovery", + "title": "Guest Recovery", + "description": "Turning a failed experience around without giving away the room.", + "category": "Service", + "difficulty": "advanced", + "xp": 220, + "estimated_minutes": 22, + "proof_skill": "Guest Recovery", + "skill_id": "server", + "target_level": "advanced", + "required_level": "intermediate", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Acknowledges without excuses", + "Offers a concrete remedy", + "Protects the table experience" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a guest recovery scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "Acknowledges without excuses", + "weight": 40 + }, + { + "criterion": "Offers a concrete remedy", + "weight": 30 + }, + { + "criterion": "Protects the table experience", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 6 + }, + { + "id": "mod_server_high_volume_service_operations", + "title": "High-Volume Service Operations", + "description": "Three hundred covers, one pass, and a plan that survives contact.", + "category": "Service", + "difficulty": "advanced", + "xp": 220, + "estimated_minutes": 25, + "proof_skill": "High-Volume Service Operations", + "skill_id": "server", + "target_level": "advanced", + "required_level": "intermediate", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Plans the run", + "Holds the standard at volume", + "Adjusts without dropping tables" + ], + "challenge": { + "type": "video", + "prompt": "Record yourself demonstrating high-volume service operations as you would on a live shift.", + "rubric": [ + { + "criterion": "Plans the run", + "weight": 40 + }, + { + "criterion": "Holds the standard at volume", + "weight": 30 + }, + { + "criterion": "Adjusts without dropping tables", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 7 + }, + { + "id": "mod_server_floor_leadership", + "title": "Floor Leadership", + "description": "Owning the floor: assignments, pace, and the call nobody else will make.", + "category": "Service", + "difficulty": "advanced", + "xp": 300, + "estimated_minutes": 30, + "proof_skill": "Floor Leadership", + "skill_id": "server", + "target_level": "expert", + "required_level": "advanced", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Assigns clear ownership", + "Sets and holds the pace", + "Makes the decision on time" + ], + "challenge": { + "type": "video", + "prompt": "Record yourself demonstrating floor leadership as you would on a live shift.", + "rubric": [ + { + "criterion": "Assigns clear ownership", + "weight": 40 + }, + { + "criterion": "Sets and holds the pace", + "weight": 30 + }, + { + "criterion": "Makes the decision on time", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 8 + }, + { + "id": "mod_server_service_coaching", + "title": "Service Coaching", + "description": "Correcting a teammate mid-service without deflating them.", + "category": "Service", + "difficulty": "advanced", + "xp": 300, + "estimated_minutes": 25, + "proof_skill": "Service Coaching", + "skill_id": "server", + "target_level": "expert", + "required_level": "advanced", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Names the behaviour, not the person", + "Gives an actionable correction", + "Confirms understanding" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a service coaching scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "Names the behaviour, not the person", + "weight": 40 + }, + { + "criterion": "Gives an actionable correction", + "weight": 30 + }, + { + "criterion": "Confirms understanding", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 9 + }, + { + "id": "mod_server_operations_excellence", + "title": "Operations Excellence", + "description": "Reading a BEO, staffing to it, and defending the standard to a client.", + "category": "Service", + "difficulty": "advanced", + "xp": 300, + "estimated_minutes": 28, + "proof_skill": "Operations Excellence", + "skill_id": "server", + "target_level": "expert", + "required_level": "advanced", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Reads the event brief accurately", + "Staffs to the requirement", + "Holds the standard with the client" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a operations excellence scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "Reads the event brief accurately", + "weight": 40 + }, + { + "criterion": "Staffs to the requirement", + "weight": 30 + }, + { + "criterion": "Holds the standard with the client", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 10 + }, + { + "id": "mod_bartending_bar_fundamentals", + "title": "Bar Fundamentals", + "description": "Station setup, glassware, and pouring to spec under a clock.", + "category": "Bar", + "difficulty": "beginner", + "xp": 100, + "estimated_minutes": 16, + "proof_skill": "Bar Fundamentals", + "skill_id": "bartending", + "target_level": "beginner", + "required_level": null, + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Sets up a working station", + "Pours to spec", + "Keeps the bar clean during service" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a bar fundamentals scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "Sets up a working station", + "weight": 40 + }, + { + "criterion": "Pours to spec", + "weight": 30 + }, + { + "criterion": "Keeps the bar clean during service", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 11 + }, + { + "id": "mod_bartending_responsible_service_basics", + "title": "Responsible Service Basics", + "description": "Checking age, reading a guest, and knowing where the line is.", + "category": "Bar", + "difficulty": "beginner", + "xp": 100, + "estimated_minutes": 14, + "proof_skill": "Responsible Service Basics", + "skill_id": "bartending", + "target_level": "beginner", + "required_level": null, + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Verifies age correctly", + "Reads intoxication signs", + "Knows the escalation path" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a responsible service basics scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "Verifies age correctly", + "weight": 40 + }, + { + "criterion": "Reads intoxication signs", + "weight": 30 + }, + { + "criterion": "Knows the escalation path", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 12 + }, + { + "id": "mod_bartending_cocktail_specs_speed", + "title": "Cocktail Specs & Speed", + "description": "Classic specs from memory, built at event pace without losing accuracy.", + "category": "Bar", + "difficulty": "intermediate", + "xp": 150, + "estimated_minutes": 20, + "proof_skill": "Cocktail Specs & Speed", + "skill_id": "bartending", + "target_level": "intermediate", + "required_level": "beginner", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Builds to spec", + "Maintains pace", + "Recovers from a mistake cleanly" + ], + "challenge": { + "type": "video", + "prompt": "Record yourself demonstrating cocktail specs & speed as you would on a live shift.", + "rubric": [ + { + "criterion": "Builds to spec", + "weight": 40 + }, + { + "criterion": "Maintains pace", + "weight": 30 + }, + { + "criterion": "Recovers from a mistake cleanly", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 13 + }, + { + "id": "mod_bartending_high_volume_bar_operations", + "title": "High-Volume Bar Operations", + "description": "Running a bar three deep: batching, sequencing, and restock timing.", + "category": "Bar", + "difficulty": "advanced", + "xp": 220, + "estimated_minutes": 24, + "proof_skill": "High-Volume Bar Operations", + "skill_id": "bartending", + "target_level": "advanced", + "required_level": "intermediate", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Batches sensibly", + "Sequences the rail", + "Restocks before it hurts" + ], + "challenge": { + "type": "video", + "prompt": "Record yourself demonstrating high-volume bar operations as you would on a live shift.", + "rubric": [ + { + "criterion": "Batches sensibly", + "weight": 40 + }, + { + "criterion": "Sequences the rail", + "weight": 30 + }, + { + "criterion": "Restocks before it hurts", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 14 + }, + { + "id": "mod_bartending_guest_judgment_behind_the_bar", + "title": "Guest Judgment Behind the Bar", + "description": "The refusal, the regular, and the colleague watching you make the call.", + "category": "Bar", + "difficulty": "advanced", + "xp": 220, + "estimated_minutes": 20, + "proof_skill": "Guest Judgment Behind the Bar", + "skill_id": "bartending", + "target_level": "advanced", + "required_level": "intermediate", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Refuses clearly and kindly", + "Protects the guest and the room", + "Documents what happened" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a guest judgment behind the bar scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "Refuses clearly and kindly", + "weight": 40 + }, + { + "criterion": "Protects the guest and the room", + "weight": 30 + }, + { + "criterion": "Documents what happened", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 15 + }, + { + "id": "mod_bartending_bar_program_leadership", + "title": "Bar Program Leadership", + "description": "Menu, training, and holding a team of bartenders to one standard.", + "category": "Bar", + "difficulty": "advanced", + "xp": 300, + "estimated_minutes": 28, + "proof_skill": "Bar Program Leadership", + "skill_id": "bartending", + "target_level": "expert", + "required_level": "advanced", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Sets a standard others can follow", + "Trains rather than corrects", + "Owns the program end to end" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a bar program leadership scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "Sets a standard others can follow", + "weight": 40 + }, + { + "criterion": "Trains rather than corrects", + "weight": 30 + }, + { + "criterion": "Owns the program end to end", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 16 + }, + { + "id": "mod_bartending_inventory_cost_control", + "title": "Inventory & Cost Control", + "description": "Par levels, variance, and explaining a number to an operator.", + "category": "Bar", + "difficulty": "advanced", + "xp": 300, + "estimated_minutes": 24, + "proof_skill": "Inventory & Cost Control", + "skill_id": "bartending", + "target_level": "expert", + "required_level": "advanced", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Tracks variance accurately", + "Explains the number", + "Proposes a real fix" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a inventory & cost control scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "Tracks variance accurately", + "weight": 40 + }, + { + "criterion": "Explains the number", + "weight": 30 + }, + { + "criterion": "Proposes a real fix", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 17 + }, + { + "id": "mod_leadership_shift_communication", + "title": "Shift Communication", + "description": "Saying the necessary thing, once, so the room hears it.", + "category": "Leadership", + "difficulty": "beginner", + "xp": 100, + "estimated_minutes": 14, + "proof_skill": "Shift Communication", + "skill_id": "leadership", + "target_level": "beginner", + "required_level": null, + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "States the message clearly", + "Confirms it landed", + "Keeps it short" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a shift communication scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "States the message clearly", + "weight": 40 + }, + { + "criterion": "Confirms it landed", + "weight": 30 + }, + { + "criterion": "Keeps it short", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 18 + }, + { + "id": "mod_leadership_giving_taking_direction", + "title": "Giving & Taking Direction", + "description": "Taking a correction well, and giving one that can be acted on.", + "category": "Leadership", + "difficulty": "beginner", + "xp": 100, + "estimated_minutes": 16, + "proof_skill": "Giving & Taking Direction", + "skill_id": "leadership", + "target_level": "beginner", + "required_level": null, + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Receives correction without friction", + "Gives an actionable instruction", + "Follows up" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a giving & taking direction scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "Receives correction without friction", + "weight": 40 + }, + { + "criterion": "Gives an actionable instruction", + "weight": 30 + }, + { + "criterion": "Follows up", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 19 + }, + { + "id": "mod_leadership_pre_shift_briefings", + "title": "Pre-Shift Briefings", + "description": "Two minutes that decide how the next six hours go.", + "category": "Leadership", + "difficulty": "intermediate", + "xp": 150, + "estimated_minutes": 18, + "proof_skill": "Pre-Shift Briefings", + "skill_id": "leadership", + "target_level": "intermediate", + "required_level": "beginner", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Covers ownership and timing", + "Sets the tone", + "Ends with questions answered" + ], + "challenge": { + "type": "video", + "prompt": "Record yourself demonstrating pre-shift briefings as you would on a live shift.", + "rubric": [ + { + "criterion": "Covers ownership and timing", + "weight": 40 + }, + { + "criterion": "Sets the tone", + "weight": 30 + }, + { + "criterion": "Ends with questions answered", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 20 + }, + { + "id": "mod_leadership_handling_conflict_on_the_floor", + "title": "Handling Conflict on the Floor", + "description": "Two staff, one section, and a room that must not notice.", + "category": "Leadership", + "difficulty": "intermediate", + "xp": 150, + "estimated_minutes": 20, + "proof_skill": "Handling Conflict on the Floor", + "skill_id": "leadership", + "target_level": "intermediate", + "required_level": "beginner", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "De-escalates in private", + "Resolves rather than defers", + "Protects service" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a handling conflict on the floor scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "De-escalates in private", + "weight": 40 + }, + { + "criterion": "Resolves rather than defers", + "weight": 30 + }, + { + "criterion": "Protects service", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 21 + }, + { + "id": "mod_leadership_section_assignment_coverage", + "title": "Section Assignment & Coverage", + "description": "Matching people to sections, and covering the gap when someone drops.", + "category": "Leadership", + "difficulty": "advanced", + "xp": 220, + "estimated_minutes": 22, + "proof_skill": "Section Assignment & Coverage", + "skill_id": "leadership", + "target_level": "advanced", + "required_level": "intermediate", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Assigns to strength", + "Plans for absence", + "Rebalances mid-service" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a section assignment & coverage scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "Assigns to strength", + "weight": 40 + }, + { + "criterion": "Plans for absence", + "weight": 30 + }, + { + "criterion": "Rebalances mid-service", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 22 + }, + { + "id": "mod_leadership_developing_your_team", + "title": "Developing Your Team", + "description": "Turning a good server into the person who runs the floor next year.", + "category": "Leadership", + "difficulty": "advanced", + "xp": 300, + "estimated_minutes": 26, + "proof_skill": "Developing Your Team", + "skill_id": "leadership", + "target_level": "expert", + "required_level": "advanced", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Identifies real potential", + "Gives progressive responsibility", + "Follows through over time" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a developing your team scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "Identifies real potential", + "weight": 40 + }, + { + "criterion": "Gives progressive responsibility", + "weight": 30 + }, + { + "criterion": "Follows through over time", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 23 + }, + { + "id": "mod_leadership_operations_under_pressure", + "title": "Operations Under Pressure", + "description": "The gala that goes wrong, and the twenty minutes that decide it.", + "category": "Leadership", + "difficulty": "advanced", + "xp": 300, + "estimated_minutes": 28, + "proof_skill": "Operations Under Pressure", + "skill_id": "leadership", + "target_level": "expert", + "required_level": "advanced", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Triages correctly", + "Communicates under load", + "Owns the outcome" + ], + "challenge": { + "type": "video", + "prompt": "Record yourself demonstrating operations under pressure as you would on a live shift.", + "rubric": [ + { + "criterion": "Triages correctly", + "weight": 40 + }, + { + "criterion": "Communicates under load", + "weight": 30 + }, + { + "criterion": "Owns the outcome", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 24 + }, + { + "id": "mod_customer_service_guest_first_impressions", + "title": "Guest First Impressions", + "description": "The greeting, the eye contact, and the tone that sets the whole visit.", + "category": "Service", + "difficulty": "beginner", + "xp": 100, + "estimated_minutes": 12, + "proof_skill": "Guest First Impressions", + "skill_id": "customer_service", + "target_level": "beginner", + "required_level": null, + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Opens warmly", + "Sets expectations", + "Hands off cleanly" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a guest first impressions scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "Opens warmly", + "weight": 40 + }, + { + "criterion": "Sets expectations", + "weight": 30 + }, + { + "criterion": "Hands off cleanly", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 25 + }, + { + "id": "mod_customer_service_reading_the_room", + "title": "Reading the Room", + "description": "Knowing when a table wants attention and when it wants to be left alone.", + "category": "Service", + "difficulty": "beginner", + "xp": 100, + "estimated_minutes": 14, + "proof_skill": "Reading the Room", + "skill_id": "customer_service", + "target_level": "beginner", + "required_level": null, + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Reads guest signals", + "Times the approach", + "Adjusts to the table" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a reading the room scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "Reads guest signals", + "weight": 40 + }, + { + "criterion": "Times the approach", + "weight": 30 + }, + { + "criterion": "Adjusts to the table", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 26 + }, + { + "id": "mod_customer_service_handling_complaints", + "title": "Handling Complaints", + "description": "Hearing the complaint behind the complaint, and fixing that one.", + "category": "Service", + "difficulty": "intermediate", + "xp": 150, + "estimated_minutes": 18, + "proof_skill": "Handling Complaints", + "skill_id": "customer_service", + "target_level": "intermediate", + "required_level": "beginner", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Listens before solving", + "Identifies the real issue", + "Resolves within authority" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a handling complaints scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "Listens before solving", + "weight": 40 + }, + { + "criterion": "Identifies the real issue", + "weight": 30 + }, + { + "criterion": "Resolves within authority", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 27 + }, + { + "id": "mod_customer_service_service_recovery_basics", + "title": "Service Recovery Basics", + "description": "What you can offer, what you cannot, and how to escalate fast.", + "category": "Service", + "difficulty": "intermediate", + "xp": 150, + "estimated_minutes": 16, + "proof_skill": "Service Recovery Basics", + "skill_id": "customer_service", + "target_level": "intermediate", + "required_level": "beginner", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Offers a concrete remedy", + "Stays within policy", + "Escalates without stalling" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a service recovery basics scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "Offers a concrete remedy", + "weight": 40 + }, + { + "criterion": "Stays within policy", + "weight": 30 + }, + { + "criterion": "Escalates without stalling", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 28 + }, + { + "id": "mod_customer_service_vip_high_stakes_guests", + "title": "VIP & High-Stakes Guests", + "description": "The head table, the client, and the guest everyone is watching.", + "category": "Service", + "difficulty": "advanced", + "xp": 220, + "estimated_minutes": 20, + "proof_skill": "VIP & High-Stakes Guests", + "skill_id": "customer_service", + "target_level": "advanced", + "required_level": "intermediate", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Handles scrutiny calmly", + "Anticipates needs", + "Protects discretion" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a vip & high-stakes guests scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "Handles scrutiny calmly", + "weight": 40 + }, + { + "criterion": "Anticipates needs", + "weight": 30 + }, + { + "criterion": "Protects discretion", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 29 + }, + { + "id": "mod_customer_service_client_relations", + "title": "Client Relations", + "description": "The conversation with the person who books the next event.", + "category": "Service", + "difficulty": "advanced", + "xp": 220, + "estimated_minutes": 22, + "proof_skill": "Client Relations", + "skill_id": "customer_service", + "target_level": "advanced", + "required_level": "intermediate", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Represents the operation", + "Handles pushback", + "Secures the follow-up" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a client relations scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "Represents the operation", + "weight": 40 + }, + { + "criterion": "Handles pushback", + "weight": 30 + }, + { + "criterion": "Secures the follow-up", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 30 + }, + { + "id": "mod_food_safety_personal_hygiene_standards", + "title": "Personal Hygiene Standards", + "description": "Handwashing, gloves, and the habits an inspector looks for first.", + "category": "Kitchen", + "difficulty": "beginner", + "xp": 100, + "estimated_minutes": 10, + "proof_skill": "Personal Hygiene Standards", + "skill_id": "food_safety", + "target_level": "beginner", + "required_level": null, + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Follows hand hygiene", + "Uses gloves correctly", + "Presents to standard" + ], + "challenge": { + "type": "photo_identify", + "prompt": "Mark everything a personal hygiene standards check should catch in this photo.", + "rubric": [ + { + "criterion": "Follows hand hygiene", + "weight": 40 + }, + { + "criterion": "Uses gloves correctly", + "weight": 30 + }, + { + "criterion": "Presents to standard", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 31 + }, + { + "id": "mod_food_safety_temperature_control_haccp", + "title": "Temperature Control & HACCP", + "description": "Danger zone, hold times, and the log that proves you held it.", + "category": "Kitchen", + "difficulty": "intermediate", + "xp": 150, + "estimated_minutes": 18, + "proof_skill": "Temperature Control & HACCP", + "skill_id": "food_safety", + "target_level": "intermediate", + "required_level": "beginner", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Knows the danger zone", + "Records holds accurately", + "Acts on a violation" + ], + "challenge": { + "type": "photo_identify", + "prompt": "Mark everything a temperature control & haccp check should catch in this photo.", + "rubric": [ + { + "criterion": "Knows the danger zone", + "weight": 40 + }, + { + "criterion": "Records holds accurately", + "weight": 30 + }, + { + "criterion": "Acts on a violation", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 32 + }, + { + "id": "mod_food_safety_allergen_management", + "title": "Allergen Management", + "description": "Stopping the plate, checking the ticket, and saying it out loud.", + "category": "Kitchen", + "difficulty": "intermediate", + "xp": 150, + "estimated_minutes": 16, + "proof_skill": "Allergen Management", + "skill_id": "food_safety", + "target_level": "intermediate", + "required_level": "beginner", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Stops the plate", + "Verifies with the kitchen", + "Communicates to the guest" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a allergen management scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "Stops the plate", + "weight": 40 + }, + { + "criterion": "Verifies with the kitchen", + "weight": 30 + }, + { + "criterion": "Communicates to the guest", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 33 + }, + { + "id": "mod_food_safety_food_safety_auditing", + "title": "Food Safety Auditing", + "description": "Walking a kitchen the way an inspector does, and writing it up.", + "category": "Kitchen", + "difficulty": "advanced", + "xp": 220, + "estimated_minutes": 22, + "proof_skill": "Food Safety Auditing", + "skill_id": "food_safety", + "target_level": "advanced", + "required_level": "intermediate", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Finds the violations", + "Ranks by severity", + "Writes an actionable report" + ], + "challenge": { + "type": "photo_identify", + "prompt": "Mark everything a food safety auditing check should catch in this photo.", + "rubric": [ + { + "criterion": "Finds the violations", + "weight": 40 + }, + { + "criterion": "Ranks by severity", + "weight": 30 + }, + { + "criterion": "Writes an actionable report", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 34 + }, + { + "id": "mod_food_safety_incident_response_reporting", + "title": "Incident Response & Reporting", + "description": "A suspected foodborne incident, in the hour it matters.", + "category": "Kitchen", + "difficulty": "advanced", + "xp": 220, + "estimated_minutes": 18, + "proof_skill": "Incident Response & Reporting", + "skill_id": "food_safety", + "target_level": "advanced", + "required_level": "intermediate", + "completion_criteria": [ + "Work through the training material", + "Submit evidence for the challenge" + ], + "verification_criteria": [ + "Isolates the product", + "Documents accurately", + "Notifies the right people" + ], + "challenge": { + "type": "roleplay", + "prompt": "Work through a incident response & reporting scenario with Owliver, start to finish.", + "ai_persona": "A guest or teammate in a live service situation.", + "rubric": [ + { + "criterion": "Isolates the product", + "weight": 40 + }, + { + "criterion": "Documents accurately", + "weight": 30 + }, + { + "criterion": "Notifies the right people", + "weight": 30 + } + ] + }, + "unlock_requirements": { + "min_shifts": 0, + "min_reliability": 0, + "required_badges": [] + }, + "quiz": [], + "pass_score": 70, + "status": "active", + "created_date": "2026-06-20T09:00:00.000Z", + "_order": 35 + } + ], + "Badge": [ + { + "id": "badge_service", + "name": "Service Fundamentals", + "description": "Verified coursed table service.", + "level": "bronze", + "requirements": "Complete Fine Dining Service Standards", + "verification_status": "verified", + "created_date": "2026-06-10T09:00:00.000Z" + }, + { + "id": "badge_responsible", + "name": "Responsible Service", + "description": "Verified responsible alcohol service judgment.", + "level": "silver", + "requirements": "Complete Responsible Alcohol Service", + "expiration_months": 24, + "verification_status": "verified", + "created_date": "2026-06-12T09:00:00.000Z" + }, + { + "id": "badge_safety", + "name": "Safety Aware", + "description": "Verified hazard identification.", + "level": "bronze", + "requirements": "Complete Kitchen Safety & Hazard Spotting", + "verification_status": "verified", + "created_date": "2026-06-14T09:00:00.000Z" + }, + { + "id": "badge_leader", + "name": "Floor Leader", + "description": "Verified floor team leadership.", + "level": "gold", + "requirements": "Complete Leading a Floor Team", + "verification_status": "verified", + "created_date": "2026-06-16T09:00:00.000Z" + } + ], + "LearningPath": [ + { + "id": "path_server", + "name": "Event Server Track", + "target_role": "Event Server", + "description": "From first shift to a floor a captain can trust.", + "difficulty": "beginner", + "steps": [ + { + "order": 1, + "course_id": "course_service_basics", + "course_title": "Fine Dining Service Standards" + }, + { + "order": 2, + "course_id": "course_kitchen_safety", + "course_title": "Kitchen Safety & Hazard Spotting" + }, + { + "order": 3, + "course_id": "course_leadership", + "course_title": "Leading a Floor Team" + } + ], + "created_date": "2026-06-18T09:00:00.000Z" + }, + { + "id": "path_bartender", + "name": "Bartender Track", + "target_role": "Bartender", + "description": "Speed, specs, and the judgment to run a bar alone.", + "difficulty": "intermediate", + "steps": [ + { + "order": 1, + "course_id": "course_responsible_service", + "course_title": "Responsible Alcohol Service" + }, + { + "order": 2, + "course_id": "course_service_basics", + "course_title": "Fine Dining Service Standards" + } + ], + "created_date": "2026-06-18T09:00:00.000Z" + } + ], + "Certification": [ + { + "id": "cert_1", + "name": "ServSafe", + "created_date": "2026-06-01T09:00:00.000Z" + }, + { + "id": "cert_2", + "name": "TIPS Certified", + "created_date": "2026-06-01T09:00:00.000Z" + }, + { + "id": "cert_3", + "name": "RBS Alcohol Server", + "created_date": "2026-06-01T09:00:00.000Z" + }, + { + "id": "cert_4", + "name": "Guard Card", + "created_date": "2026-06-01T09:00:00.000Z" + }, + { + "id": "cert_5", + "name": "CPR / First Aid", + "created_date": "2026-06-01T09:00:00.000Z" + }, + { + "id": "cert_6", + "name": "Food Handler Card", + "created_date": "2026-06-01T09:00:00.000Z" + }, + { + "id": "cert_7", + "name": "Forklift Operator", + "created_date": "2026-06-01T09:00:00.000Z" + }, + { + "id": "cert_8", + "name": "OSHA 10", + "created_date": "2026-06-01T09:00:00.000Z" + } + ], + "RoleCategory": [ + { + "id": "role_1", + "name": "Bartender", + "created_date": "2026-06-01T09:00:00.000Z" + }, + { + "id": "role_2", + "name": "Server", + "created_date": "2026-06-01T09:00:00.000Z" + }, + { + "id": "role_3", + "name": "Security", + "created_date": "2026-06-01T09:00:00.000Z" + }, + { + "id": "role_4", + "name": "Chef", + "created_date": "2026-06-01T09:00:00.000Z" + }, + { + "id": "role_5", + "name": "Picker", + "created_date": "2026-06-01T09:00:00.000Z" + }, + { + "id": "role_6", + "name": "Host", + "created_date": "2026-06-01T09:00:00.000Z" + }, + { + "id": "role_7", + "name": "Barback", + "created_date": "2026-06-01T09:00:00.000Z" + }, + { + "id": "role_8", + "name": "Line Cook", + "created_date": "2026-06-01T09:00:00.000Z" + }, + { + "id": "role_9", + "name": "Event Lead", + "created_date": "2026-06-01T09:00:00.000Z" + } + ], + "UserActivity": [ + { + "id": "act_1", + "event_type": "create_position", + "user_email": "demo@krow.app", + "user_name": "Alex Rivera", + "account_type": "employer", + "details": "Created Banquet Captain – Hotel Events", + "created_date": "2026-07-18T09:00:00.000Z" + }, + { + "id": "act_2", + "event_type": "create_position", + "user_email": "demo@krow.app", + "user_name": "Alex Rivera", + "account_type": "employer", + "details": "Created Executive Chef – Catering", + "created_date": "2026-07-27T09:00:00.000Z" + }, + { + "id": "act_3", + "event_type": "apply_job", + "user_email": "antoine.dubois@email.com", + "user_name": "Chef Antoine Dubois", + "account_type": "talent", + "details": "Applied to Executive Chef – Catering", + "created_date": "2026-07-24T09:00:00.000Z" + }, + { + "id": "act_4", + "event_type": "apply_job", + "user_email": "marcus.w@email.com", + "user_name": "Marcus Williams", + "account_type": "talent", + "details": "Applied to Event Security Officer", + "created_date": "2026-07-24T09:00:00.000Z" + }, + { + "id": "act_5", + "event_type": "apply_job", + "user_email": "marco.rivera@email.com", + "user_name": "Marco Rivera", + "account_type": "talent", + "details": "Applied to Experienced Bartender – Corporate Events", + "created_date": "2026-07-24T09:00:00.000Z" + }, + { + "id": "act_6", + "event_type": "screen_candidate", + "user_email": "demo@krow.app", + "user_name": "Alex Rivera", + "account_type": "employer", + "details": "AI screened 3 candidates", + "created_date": "2026-07-24T09:00:00.000Z" + }, + { + "id": "act_7", + "event_type": "hire_candidate", + "user_email": "demo@krow.app", + "user_name": "Alex Rivera", + "account_type": "employer", + "details": "Hired Chef Antoine Dubois", + "created_date": "2026-07-25T09:00:00.000Z" + }, + { + "id": "act_8", + "event_type": "hire_candidate", + "user_email": "demo@krow.app", + "user_name": "Alex Rivera", + "account_type": "employer", + "details": "Hired Marcus Williams", + "created_date": "2026-07-25T09:00:00.000Z" + }, + { + "id": "act_9", + "event_type": "hire_candidate", + "user_email": "demo@krow.app", + "user_name": "Alex Rivera", + "account_type": "employer", + "details": "Hired Marco Rivera", + "created_date": "2026-07-25T09:00:00.000Z" + }, + { + "id": "act_10", + "event_type": "apply_job", + "user_email": "sofia.mendez@email.com", + "user_name": "Sofia Mendez", + "account_type": "talent", + "details": "Applied to Event Server – Fine Dining", + "created_date": "2026-08-02T09:00:00.000Z" + }, + { + "id": "act_11", + "event_type": "screen_candidate", + "user_email": "demo@krow.app", + "user_name": "Alex Rivera", + "account_type": "employer", + "details": "AI screened Sofia Mendez", + "created_date": "2026-08-05T09:00:00.000Z" + }, + { + "id": "act_12", + "event_type": "start_interview", + "user_email": "tanya.cruz@email.com", + "user_name": "Tanya Cruz", + "account_type": "talent", + "details": "Completed AI interview", + "created_date": "2026-08-05T09:00:00.000Z" + }, + { + "id": "act_13", + "event_type": "login", + "user_email": "demo@krow.app", + "user_name": "Alex Rivera", + "account_type": "employer", + "details": "", + "created_date": "2026-08-06T09:00:00.000Z" + }, + { + "id": "act_14", + "event_type": "signup", + "user_email": "new.demo2@example.com", + "user_name": "New Talent Demo", + "account_type": "talent", + "details": "", + "created_date": "2026-08-05T09:00:00.000Z" + }, + { + "id": "act_15", + "event_type": "login", + "user_email": "maria.gonzalez@example.com", + "user_name": "Maria Gonzalez", + "account_type": "talent", + "details": "", + "created_date": "2026-08-06T09:00:00.000Z" + } + ], + "Assignment": [], + "Evidence": [ + { + "id": "ev_maria_service", + "course_id": "course_service_basics", + "course_title": "Fine Dining Service Standards", + "skill": "Coursed Table Service", + "worker_email": "maria.gonzalez@example.com", + "worker_name": "Maria Gonzalez", + "type": "roleplay", + "media_url": "", + "transcript": "Acknowledged the cold entrée without excuses, offered to re-fire immediately with a comped course, and kept the rest of the table on timing.", + "ai_verdict": "verified", + "ai_score": 96, + "ai_rubric": { + "Acknowledges without excuses": 95, + "Offers a concrete remedy": 98, + "Protects the table experience": 94 + }, + "ai_feedback": "Owned the problem in one sentence and moved straight to a remedy. Exactly the instinct this role needs.", + "supervisor_verified": true, + "supervisor_name": "Luis Ortega", + "verified_date": "2026-06-23T09:00:00.000Z", + "created_date": "2026-06-22T09:00:00.000Z" + }, + { + "id": "ev_maria_leadership", + "course_id": "course_leadership", + "course_title": "Leading a Floor Team", + "skill": "Team Leadership", + "worker_email": "maria.gonzalez@example.com", + "worker_name": "Maria Gonzalez", + "type": "video", + "media_url": "", + "transcript": "Pre-shift briefing for eight servers covering sections, coursing timing, and the VIP head table.", + "ai_verdict": "verified", + "ai_score": 89, + "ai_rubric": { + "Assigns clear ownership": 92, + "Communicates timing": 90, + "Sets a tone people follow": 85 + }, + "ai_feedback": "Assignments were unambiguous and the timing was concrete. Could invite more questions at the close.", + "supervisor_verified": true, + "supervisor_name": "Luis Ortega", + "verified_date": "2026-07-15T09:00:00.000Z", + "created_date": "2026-07-14T09:00:00.000Z" + }, + { + "id": "ev_iliana_service", + "course_id": "course_service_basics", + "course_title": "Fine Dining Service Standards", + "skill": "Coursed Table Service", + "worker_email": "iortega@legendaryeventstaff.com", + "worker_name": "Iliana Ortega", + "type": "roleplay", + "media_url": "", + "transcript": "Apologized, offered to check with the kitchen, and returned with a timing update.", + "ai_verdict": "verified", + "ai_score": 78, + "ai_rubric": { + "Acknowledges without excuses": 82, + "Offers a concrete remedy": 74, + "Protects the table experience": 78 + }, + "ai_feedback": "Good instinct to go straight to the kitchen. Commit to a specific remedy rather than only an update.", + "supervisor_verified": false, + "created_date": "2026-07-28T09:00:00.000Z" + } + ], + "User": [ + { + "id": "user_demo", + "full_name": "Alex Rivera", + "email": "demo@krow.app", + "role": "admin", + "account_type": "employer", + "created_date": "2026-06-01T09:00:00.000Z", + "preferences": { + "owliverDefault": true, + "compactDensity": false, + "emailDigest": true + } + } + ] + } +} \ No newline at end of file