@promptowl/contextnest-community 1.22.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/API.md +2092 -0
  2. package/CONFIGURATION.md +5 -2
  3. package/README.md +62 -10
  4. package/STEWARDSHIP.md +229 -0
  5. package/dist/{chunk-PX3P4FTU.js → chunk-2TPQTN4Y.js} +5 -4
  6. package/dist/{chunk-OQXZ43HG.js → chunk-5EZOPA47.js} +40 -10
  7. package/dist/{chunk-MY4JIWQD.js → chunk-I3CSD6CK.js} +1 -1
  8. package/dist/{chunk-W5ILNGPD.js → chunk-KIAAEHWL.js} +80 -28
  9. package/dist/{chunk-J2OQ3MEB.js → chunk-LA3VTQ22.js} +63 -1
  10. package/dist/{chunk-ZAV4QUZA.js → chunk-XUIWAWDO.js} +28 -33
  11. package/dist/{chunk-6WESEA75.js → chunk-ZTT4U4NE.js} +2 -2
  12. package/dist/{client-CSCHXUNX.js → client-KEY4PYJH.js} +1 -1
  13. package/dist/{engine-XKRP7GOQ.js → engine-S3QBQ7LH.js} +2 -2
  14. package/dist/{external-edit-service-TXW7SXSF.js → external-edit-service-6FNFPOJJ.js} +3 -3
  15. package/dist/{grants-service-UZ2MGDO5.js → grants-service-UT3EQ3R7.js} +2 -2
  16. package/dist/index.js +1261 -305
  17. package/dist/{migrations.postgres-KKICIH2C.js → migrations.postgres-AIQ7WSU7.js} +60 -4
  18. package/dist/{review-service-XFWTENTN.js → review-service-HHGOTO6P.js} +6 -6
  19. package/dist/{stewardship-service-5HBQGDMK.js → stewardship-service-4DHNRULG.js} +3 -3
  20. package/dist/{version-service-TKNYJMCM.js → version-service-KIOQME6U.js} +3 -3
  21. package/dist/web3/assets/ActivityTracePage-BOZHgJ8S.js +1 -0
  22. package/dist/web3/assets/AgentDocsPage-CIiaBqMy.js +1 -0
  23. package/dist/web3/assets/CollaboratorManager-By91wrIr.js +1 -0
  24. package/dist/web3/assets/CollaboratorsTab-D1EE64Fs.js +1 -0
  25. package/dist/web3/assets/DocumentEditor-DrQfrW3d.js +36 -0
  26. package/dist/web3/assets/DocumentsTab-Vba-AzBv.js +6 -0
  27. package/dist/web3/assets/ExternalEditsTab-BSzAQIGM.js +1 -0
  28. package/dist/web3/assets/MarkdownEditor-74N_naNQ.css +1 -0
  29. package/dist/web3/assets/MarkdownEditor-C9zqZjC5.js +643 -0
  30. package/dist/web3/assets/NestPageHeader-CoLUV1Oz.js +1 -0
  31. package/dist/web3/assets/NestView-CTZ55tXD.js +63 -0
  32. package/dist/web3/assets/OverviewTab-Dxi-Hu-N.js +1 -0
  33. package/dist/web3/assets/PersonCombobox-CPOlNs6G.js +1 -0
  34. package/dist/web3/assets/ReasonDialog-DgLxfRWv.js +1 -0
  35. package/dist/web3/assets/ReviewActions-OfOdqzCn.js +6 -0
  36. package/dist/web3/assets/ReviewTab-CyX8yxd3.js +1 -0
  37. package/dist/web3/assets/StewardsTab-DBEEfAQI.js +1 -0
  38. package/dist/web3/assets/SubmitForReviewModal-Ci2DdWs0.js +1 -0
  39. package/dist/web3/assets/alert-dialog-DTozKlmV.js +7 -0
  40. package/dist/web3/assets/arrow-left-BCKt4CwQ.js +6 -0
  41. package/dist/web3/assets/backlinks-CYd4xENL.js +24 -0
  42. package/dist/web3/assets/card-XG2dP5Xf.js +1 -0
  43. package/dist/web3/assets/chevron-left-BPwUT9DS.js +6 -0
  44. package/dist/web3/assets/circle-check-DI8IBGeo.js +6 -0
  45. package/dist/web3/assets/circle-x-VKVhC88W.js +6 -0
  46. package/dist/web3/assets/code-xml-CMabYZvH.js +6 -0
  47. package/dist/web3/assets/corner-down-right-BGxwK6wH.js +6 -0
  48. package/dist/web3/assets/count-skeleton-DMbdpscX.js +1 -0
  49. package/dist/web3/assets/dates-BCxbm4_q.js +1 -0
  50. package/dist/web3/assets/earth-BEp1EKTo.js +6 -0
  51. package/dist/web3/assets/file-exclamation-point-r8qZGIjP.js +6 -0
  52. package/dist/web3/assets/folder-input-Cmtx1Jhk.js +11 -0
  53. package/dist/web3/assets/folder-target-CUSWqImF.js +1 -0
  54. package/dist/web3/assets/index-BM-h3DwI.css +1 -0
  55. package/dist/web3/assets/index-C1wTSjfP.js +29 -0
  56. package/dist/web3/assets/index-EaX2yql0.js +389 -0
  57. package/dist/web3/assets/page-B3yvQvHy.js +1 -0
  58. package/dist/web3/assets/page-BExMvttJ.js +1 -0
  59. package/dist/web3/assets/page-BUOADv2X.js +1 -0
  60. package/dist/web3/assets/page-BYUFhYWZ.js +1 -0
  61. package/dist/web3/assets/page-Bicf02Cz.js +1 -0
  62. package/dist/web3/assets/page-BsPMDm5d.js +45 -0
  63. package/dist/web3/assets/page-ByyVLDVo.js +16 -0
  64. package/dist/web3/assets/page-CC0NJi8v.js +1 -0
  65. package/dist/web3/assets/page-CHb0F5hV.js +24 -0
  66. package/dist/web3/assets/page-CPjWQNKx.js +11 -0
  67. package/dist/web3/assets/page-CQt3ZGbf.js +2 -0
  68. package/dist/web3/assets/page-Cj1NetQ-.js +1 -0
  69. package/dist/web3/assets/page-CtaW65El.js +1 -0
  70. package/dist/web3/assets/page-DGOL9l8i.js +6 -0
  71. package/dist/web3/assets/page-title-ClvvBsIo.js +1 -0
  72. package/dist/web3/assets/play-C5YNKpAH.js +6 -0
  73. package/dist/web3/assets/refresh-cw-BhHzSH9m.js +6 -0
  74. package/dist/web3/assets/scroll-area-dRWncRqa.css +1 -0
  75. package/dist/web3/assets/scroll-area-hz-xtayP.js +1 -0
  76. package/dist/web3/assets/select-DBFTrUmI.js +6 -0
  77. package/dist/web3/assets/send-0CHvmluT.js +6 -0
  78. package/dist/web3/assets/settings-CeeF4LEb.js +6 -0
  79. package/dist/web3/assets/share-2-CMnOlOt-.js +6 -0
  80. package/dist/web3/assets/tag-DQ_6J5Gv.js +11 -0
  81. package/dist/web3/assets/trash-2-HBi-Pslz.js +6 -0
  82. package/dist/web3/assets/triangle-alert-CDy8-7sv.js +6 -0
  83. package/dist/web3/assets/user-plus-CvBNcpn4.js +6 -0
  84. package/dist/web3/assets/x-By7piikG.js +6 -0
  85. package/dist/web3/assets/zap-C2T8riBp.js +11 -0
  86. package/dist/web3/index.html +2 -2
  87. package/package.json +4 -2
  88. package/dist/web3/assets/index-C2hW_9j9.js +0 -1382
  89. package/dist/web3/assets/index-T4pilFNL.css +0 -1
package/API.md ADDED
@@ -0,0 +1,2092 @@
1
+ # ContextNest Community — API Reference
2
+
3
+ Base URL: `http://localhost:3838`
4
+ Default port: `3838` (override via `PORT` env)
5
+
6
+ ## Authentication
7
+
8
+ Two methods, evaluated in order:
9
+
10
+ 1. **Cookie session** — `cnst_session=<id>` (set by `/auth/login` and `/auth/register`). Browser default.
11
+ 2. **Bearer API key** — `Authorization: Bearer cnst_<token>` (issued by `/auth/keys`). Use for Postman / external tools.
12
+
13
+ Anonymous open mode (`AUTH_MODE=open`) bypasses auth on most read endpoints.
14
+
15
+ All bodies are JSON unless noted. All errors return `{ "error": "msg" }` with appropriate HTTP status.
16
+
17
+ ---
18
+
19
+ ## 1. Auth
20
+
21
+ ### POST `/auth/register`
22
+ Public **self sign-up** — creates a brand-new account only. Gated by `MANUAL_SIGN_IN`: allowed only in `open`; `invite-only` and `disabled` return `403`. It never sets the password on an already-provisioned (invited) account — that returns `409` (no email verification exists, so self-claiming would be account takeover). Invited users get a password from their admin (`/auth/invite` or admin reset), then log in and change it.
23
+
24
+ Body:
25
+ ```json
26
+ { "email": "user@x.com", "password": "secret123", "name": "Optional Name" }
27
+ ```
28
+
29
+ Behavior:
30
+ - Email not in DB → create user → 201 + session cookie.
31
+ - Email in DB with `is_invited = 1` → **claim flow**: overwrite `password_hash`, set `name` if provided, flip `is_invited → 0`. Same `user.id` retained, so existing `api_keys` / `nest_collaborators` / `stewards` rows stay linked.
32
+ - Email in DB with `is_invited = 0` → 400 `Email already registered`.
33
+
34
+ Response (201):
35
+ ```json
36
+ { "user": { "id": "uuid", "email": "...", "name": null, "is_admin": false } }
37
+ ```
38
+
39
+ Rate limit: per-IP.
40
+
41
+ ### POST `/auth/login`
42
+ Body:
43
+ ```json
44
+ { "email": "user@x.com", "password": "secret123" }
45
+ ```
46
+ 200 + session cookie. 401 `Invalid credentials`. Rate-limited per IP and per email. Gated by `MANUAL_SIGN_IN` — returns `403` when set to `disabled` (`invite-only` still allows login).
47
+
48
+ ### POST `/auth/logout`
49
+ Clears session cookie. 200.
50
+
51
+ ### POST `/auth/keys` (auth required)
52
+ Mints an API key for the caller. A user may hold **several** keys — one per place they connect from — so rotating the key for one client doesn't disconnect the rest.
53
+
54
+ `label` is **required** and must be unique among the caller's keys (compared case-insensitively, after trimming): once the plaintext is gone the name is the only thing that tells two keys apart, so a list of identical entries makes revoking a guess. **400** if it's missing, blank, not a string, or longer than 60 characters; **409** on a duplicate name or at the per-user ceiling (20). Keys created before this rule keep their existing (possibly absent) label.
55
+ ```json
56
+ { "label": "cli", "nest_id": "optional — scope the key to one nest" }
57
+ ```
58
+ Response (201):
59
+ ```json
60
+ { "api_key": "cnst_...", "id": "uuid", "key_prefix": "cnst_abc123...", "label": "cli" }
61
+ ```
62
+ **Plaintext shown once.** Omit `nest_id` for a **user-level key** (works on every nest the user can access — what the Connect dialog mints); a nest-scoped key is rejected on other nests' MCP/API paths.
63
+
64
+ ### POST `/auth/keys/rotate` (auth required)
65
+ Replaces **one** key and returns its new plaintext; the caller's other keys are untouched.
66
+
67
+ `key_id` names the target. Omitting it is only accepted when the account holds exactly one key (pre-multi-key clients) — with several keys and no `key_id` the server answers **400** rather than guessing which to replace. **404** if the id isn't the caller's.
68
+
69
+ Scope resolution on the optional `nest_id` field: **absent** → keep the prior scope; **`null` (or `""`)** → clear to a user-level key; **a nest id** → scope to that nest. Same for `label` (absent keeps the prior label). Supplying a `label` that matches a *different* key of the caller's is **409**; renaming a key to what it is already called is fine.
70
+ ```json
71
+ { "key_id": "uuid", "label": "optional", "nest_id": "optional" }
72
+ ```
73
+
74
+ ### GET `/auth/keys` (auth required)
75
+ Lists the caller's keys — id, prefix, label, `nest_id`, `created_at`, `last_used_at` — oldest first. Never returns plaintext.
76
+
77
+ ### DELETE `/auth/keys/:keyId` (auth required)
78
+ Revoke one key. 404 if the id isn't the caller's. Anything still using it stops working immediately.
79
+
80
+ ### POST `/auth/password` (auth required)
81
+ ```json
82
+ { "current_password": "...", "new_password": "..." }
83
+ ```
84
+
85
+ ### PATCH `/auth/profile` (auth required)
86
+ Set the caller's display name (≤ 80 chars, trimmed). An empty string clears it, so the UI shows the email. Needs a session or API key even under `AUTH_MODE=open` — the anonymous caller gets `401`.
87
+ ```json
88
+ { "name": "Alan" }
89
+ ```
90
+ Response: `{ "ok": true, "name": "Alan" }`
91
+
92
+ ### POST `/auth/invite` (admin only)
93
+ Creates an invited user and returns a **temporary password** (once) for the admin to hand off. No API key is minted — teammates sign in with the temp password, then mint their own key from the Connect modal.
94
+ ```json
95
+ { "email": "teammate@x.com" }
96
+ ```
97
+ - New email → user row with `is_invited = 1` and a temporary password.
98
+ - Existing, not-yet-claimed email (`is_invited = 1`, hasn't logged in) → refreshes the temp password.
99
+ - Existing, claimed email (has logged in or set their own password, `is_invited = 0`) → `409`; use Reset password instead.
100
+
101
+ `is_invited` clears to `0` on the teammate's first successful login (or when they set their own password).
102
+
103
+ Response (201):
104
+ ```json
105
+ {
106
+ "temporary_password": "...",
107
+ "user": { "id": "...", "email": "..." },
108
+ "message": "Share this temporary password securely — it won't be shown again."
109
+ }
110
+ ```
111
+
112
+ ### GET `/auth/teammates` (admin only)
113
+ Returns all users + key counts. **Sorted: admins first, then `created_at DESC`.**
114
+ ```json
115
+ {
116
+ "teammates": [
117
+ {
118
+ "id": "uuid",
119
+ "email": "...",
120
+ "name": null,
121
+ "is_admin": true,
122
+ "is_invited": 0,
123
+ "key_count": 1,
124
+ "last_active": "2026-05-15T10:00:00Z"
125
+ }
126
+ ],
127
+ "pending_stewards": ["unregistered@x.com"]
128
+ }
129
+ ```
130
+ `is_invited = 1` → user was placeholder-created, has not yet claimed the account.
131
+
132
+ ### POST `/auth/admin/reset-password/:userId` (admin only)
133
+ In-platform password reset — **no email server**. Provide a password, or omit it to have one generated and returned **once** for the admin to share.
134
+ ```json
135
+ { "password": "optional, min 8 chars" }
136
+ ```
137
+ Response: `{ "ok": true, "email": "...", "keys_revoked": false, "temporary_password": "<only when generated>" }`. Invalidates the user's sessions but **keeps their API keys** (a reset shouldn't break a teammate's CLI/MCP). Revoke a key separately (`DELETE /auth/keys/:keyId`) if it may be compromised. The hash is never returned.
138
+
139
+ ### DELETE `/auth/users/:userId` (admin only)
140
+ Removes a user — revokes api_keys, sessions, steward rows, and collaborator grants, then deletes the account. `400` if you target yourself; `409` if the user still owns nests (transfer/delete them first). Authored docs on disk are unaffected.
141
+
142
+ ### POST `/auth/promptowl`
143
+ SSO via PromptOwl. Used by web UI; not typically called from Postman. Subject to `PROMPTOWL_SIGN_IN_GATE` — when `admin-only`/`disabled`, non-admins (or everyone) get `403` here.
144
+
145
+ ### GET `/auth/admin-status`
146
+ Returns whether the caller is the license admin + license metadata.
147
+
148
+ ### POST `/auth/device` / GET `/auth/device/poll`
149
+ Device-flow login. Also gated by `PROMPTOWL_SIGN_IN_GATE`.
150
+
151
+ ### GET `/auth/sso`
152
+ One-click SSO auto-login from an **official community site** (**not for self-hosted deployments** — see below). A trusted site redirects a signed-in user here with a short-lived, single-use, audience-bound HS256 JWT ticket (`?ticket=...`). The server tries the ticket's signature against every configured official community site's secret (`GET/POST/PATCH/DELETE /admin/community-sites`, plus the legacy `OFFICIAL_COMMUNITY_SSO_SECRET`/`PUBLIC_BASE_URL` env-var pair for backward compatibility), checks `aud` against this deployment's own `PUBLIC_BASE_URL` (not the matched site's `url`, which is a display/uniqueness field only), enforces one-time-use via the ticket's `jti`, then provisions the user and starts a session (same provisioning + `PROMPTOWL_SIGN_IN_GATE` as `/auth/promptowl`). On success → `302` to `/`; on failure → `302` to `/?sso_error=<code>` (the SPA shows a toast). **When no official community site is configured** (i.e. every self-hosted server, by default) the feature is disabled and the endpoint bounces to `/?sso_error=not_supported`. That is the domain lock: without a matching secret no ticket can be verified, so the feature stays exclusive to configured official sites regardless of this redirect. Error codes: `not_supported`, `missing_ticket`, `invalid_ticket`, `bad_audience`, `ticket_used`, `rate_limited`, `sign_in_restricted`, `service_error` (DB/internal fault while redeeming the ticket).
153
+
154
+ ### GET `/auth/oidc/login`
155
+ Starts generic **OIDC single sign-on** (Microsoft Entra ID, Google, or any spec-compliant IdP). Superadmin-configurable via `/admin/settings` (`oidc_*` fields) or the `OIDC_*` env vars — see `CONFIGURATION.md → Single sign-on (OIDC)`. Entirely separate from the PromptOwl flows above.
156
+
157
+ When enabled and fully configured: fetches the issuer's discovery document (cached in-process ~5 min), mints `state` + `nonce` + a PKCE S256 verifier into three short-lived (10 min) httpOnly `SameSite=Lax` cookies scoped to `Path=/auth/oidc`, and `302`s to the IdP's authorization endpoint with `response_type=code&scope=openid email profile`. When disabled/unconfigured → `302` to `/?sso_error=disabled` / `not_configured`. Rate-limited per IP.
158
+
159
+ ### GET `/auth/oidc/callback`
160
+ The redirect URI (`<base url>/auth/oidc/callback` — register this exact URI at your IdP). Verifies the `state` param against the flow cookie, exchanges the code at the token endpoint (client secret + PKCE verifier), validates the ID token with the issuer's JWKS (signature, `iss`, `aud` = client id, `exp`, and the `nonce` claim against the flow cookie), extracts the email (`email`, falling back to `preferred_username` for Entra; normalized trim+lowercase), and enforces `oidc_allowed_domains`. Existing users are signed in; unknown users are JIT-provisioned when `oidc_auto_provision` is on (name from the `name` claim), else refused. Every successful login also upserts `users.department` from the optional string `department` claim (trimmed, whitespace collapsed, capped at 120 chars; absent claim clears the stored value) — read by the `oidc_department_tagging` feature (see `CONFIGURATION.md → Department auto-tagging`). On success → session cookie (identical to a password login) + `302` to `/`. On any failure → `302` to `/?sso_error=<code>`; codes: `disabled`, `not_configured`, `discovery_failed`, `provider_error`, `state_mismatch`, `exchange_failed`, `invalid_token`, `email_not_verified` (ID token carries `email_verified: false` — an absent claim is trusted; see the trust assumption in `CONFIGURATION.md → Single sign-on (OIDC)`), `domain_not_allowed`, `not_invited`, `rate_limited`, `service_error`. Flow cookies are cleared on every outcome. Rate-limited per IP.
161
+
162
+ ### GET `/auth/oidc/logout`
163
+ RP-initiated logout for an SSO session. Deletes the current session server-side and clears the session cookie plus the `cnst_sso_session` marker. If the issuer advertises an `end_session_endpoint` (Entra/Okta/Keycloak; Google does not), `302`s the browser through it with `post_logout_redirect_uri=<base url>/` so the IdP session ends too; otherwise `302`s straight to `/`. Always clears cookies and always lands on `/`, even if discovery is unreachable. The SPA navigates here (instead of `POST /auth/logout`) only when the non-httpOnly `cnst_sso_session` marker cookie — set on OIDC callback success — is present, so password sessions keep the plain logout.
164
+
165
+ Related `/admin/settings` fields (GET/PATCH, superadmin only): `oidc_enabled`, `oidc_issuer` (https URL), `oidc_client_id`, `oidc_client_secret` (**write-only** — PATCH sets/replaces, `null` clears; GET returns only `oidc_client_secret_set: boolean`), `oidc_allowed_domains` (comma-separated), `oidc_auto_provision`, `oidc_department_tagging` (append `dept:<slugified-department>` to a new document's tags on create — create-only, no-op for users without a stored department). Enabling is refused unless issuer + client id + secret are all present.
166
+
167
+ ### POST `/auth/token-exchange`
168
+ Exchange an IdP ID token for a short-lived, scoped **MCP bearer** (RFC 8693-shaped) — for an external agent acting on behalf of a user your IdP already authenticated. No session, no API key. **Off by default; security-critical** — see `CONFIGURATION.md → Agent SSO (token exchange)`.
169
+
170
+ Body: `{ "provider": "microsoft" | "google", "subject_token": "<IdP ID token>" }`. Response `200`: `{ "access_token", "token_type": "Bearer", "expires_in" }` — send it as `Authorization: Bearer <access_token>` to `/index`, `/mcp`, and the ticket-eligible `/nests/:nestId/*` routes.
171
+
172
+ The `subject_token` is verified for signature (provider JWKS, `RS256` pinned), issuer (exact per provider — the tenant-scoped Entra issuer or Google's), audience (your configured client id, so a token minted for another app can't be replayed), and expiry (30s tolerance); then the same `email_verified` + `oidc_allowed_domains` policy as browser login, then resolve/JIT the user (`oidc_auto_provision`) and mint. JIT **fails closed**: auto-provision with an empty domain allowlist refuses with `no_account`.
173
+
174
+ `404 disabled` unless `sso_token_exchange_enabled` **and** `mcp_signing_secret` **and** that provider's toggle are all set (a disabled server doesn't advertise the endpoint). Rate-limited per IP (`429 rate_limited`). Other codes: `400` `bad_request` / `provider_disabled` / `provider_misconfigured`, `401` `invalid_subject_token`, `403` `email_not_verified` / `domain_not_allowed` / `no_account`, `500` `server_error`.
175
+
176
+ Related `/admin/settings` fields (GET/PATCH, superadmin only): `sso_token_exchange_enabled`, `mcp_signing_secret` (**write-only** — GET returns only `mcp_signing_secret_set: boolean`), `mcp_token_ttl_seconds` (30–3600), `ms_token_exchange_enabled`, `ms_client_id`, `ms_tenant_id` (directory GUID), `google_token_exchange_enabled`, `google_client_id`. Enabling a provider is refused while `oidc_auto_provision` is on and `oidc_allowed_domains` is empty.
177
+
178
+ ---
179
+
180
+ ## 2. Nests
181
+
182
+ A nest = a context vault (folder of markdown documents + governance state).
183
+
184
+ ### GET `/nests`
185
+ Returns owned + shared nests. Archived nests are **absent** — as they are from every other listing, the work inbox, cross-nest search and the MCP index. Each row carries `pinned` — the calling user's own bookmark (see `POST /nests/:nestId/pin`).
186
+ ```json
187
+ { "nests": [{ "id": "...", "user_id": "...", "name": "...", "slug": "...", "visibility": "private", "created_at": "..." }] }
188
+ ```
189
+
190
+ `?archived=1` inverts it: **only** archived nests, and only ones the caller owns (archive, restore and delete are the owner's, so nobody else has an action to take on one). Each row carries `archived_at`.
191
+
192
+ ### POST `/nests`
193
+ ```json
194
+ { "name": "My Nest", "description": "optional" }
195
+ ```
196
+ Response (201): `{ "nest": { ... } }`
197
+
198
+ ### GET `/nests/:nestId`
199
+ ```json
200
+ { "nest": { ... }, "permission": "owner" | "admin" | "write" | "read" }
201
+ ```
202
+ 404 if caller has no permission.
203
+
204
+ ### DELETE `/nests/:nestId`
205
+ Owner only. Permanent — documents, versions and governance state all go.
206
+
207
+ ### POST `/nests/:nestId/archive`
208
+ Owner only (same gate as delete, license-admin caretaker branch included). Puts the nest away without deleting anything: `{ "archived": true }`.
209
+
210
+ An archived nest is capped at `read` for **everyone**, the owner included — one guard in `resolveNestAccess`, so every REST route, MCP tool and workflow gate refuses writes. It also disappears from `GET /nests`, `/me/work`, `/me/drafts`, `GET /search`, the MCP nest index, and `GET /nests/:nestId/mcp` (404 — a pinned nest id or a nest-scoped key stops answering). Its published `/p/:slug` links return the standard not-found page. Nothing is unpublished or deleted.
211
+
212
+ **Open mode is included.** `AUTH_MODE=open` grants every caller `owner` without resolving access at all, so the cap cannot reach it either; the nest middleware checks `archived_at` directly on that path — an archived nest answers `403` to anything but a `GET` (bar the lifecycle routes, which must stay open or the nest could never be restored) and `404` on its MCP transport, exactly as in key mode.
213
+
214
+ Three surfaces sit outside the `/nests/:nestId` auth chain, so the permission cap cannot reach them and each carries its own `archived_at` check: `/p/:slug` (via `getPublishBySlug`), `POST /hooks/:token` (404 while archived — a trigger hook must not keep creating agent runs in a nest that was put away), and `schedulerTick`, which skips archived nests. All three are suspended, not disabled: restoring the nest brings its links, hooks and schedules back with no re-wiring.
215
+
216
+ Archiving also drops any pending `nest_archive` deletion request for the nest — the ask has been granted, and a row left pending could not be actioned afterwards (the honour route needs write tier, which an archived nest denies everyone). A pending `nest` **deletion** request does survive archiving and cannot be honoured from the queue while archived; the owner deletes the nest directly (`DELETE /nests/:nestId` stays open to them) or restores it first.
217
+
218
+ ### POST `/nests/:nestId/restore`
219
+ Owner only. `{ "restored": true }`. Puts everything back exactly as it was — same documents, versions, stewards, links.
220
+
221
+ Editors, who cannot archive directly, ask via `POST /nests/:nestId/request-deletion` with `targetType: "nest_archive"` (see *Deletion requests*). Honouring that request archives instead of deleting.
222
+
223
+ ### POST / DELETE `/nests/:nestId/pin`
224
+ The caller's own bookmark on this nest: `{ "pinned": true }` / `{ "pinned": false }`. Idempotent in both directions.
225
+
226
+ **Read-tier, deliberately.** A pin changes nothing about the nest and is visible to nobody else, so any caller who can see the nest may pin it — a read-only collaborator, and someone who reaches the nest only through a single document or folder share grant, both included. It is not ungated: a caller with no access at all gets `404` (not `403`), so the endpoint can't be used to probe which nest ids exist.
227
+
228
+ Idempotent under concurrency, not just in sequence: two simultaneous pins of the same nest both answer `200` rather than one tripping the composite primary key.
229
+
230
+ Pins are per `(user, nest)` in the `nest_pins` table and cascade with the nest. Each row of `GET /nests` carries `pinned` for the calling user, and the dashboard floats pinned nests to the top of the grid under either sort.
231
+
232
+ ### GET `/nests/:nestId/settings`
233
+ ```json
234
+ {
235
+ "stewardship_enabled": false,
236
+ "allow_self_approve": false,
237
+ "prime_only_review": false,
238
+ "prime_tags": ["prime-document"]
239
+ }
240
+ ```
241
+
242
+ ### PATCH `/nests/:nestId/settings` (admin/owner)
243
+ ```json
244
+ { "stewardship_enabled": true, "prime_only_review": true, "prime_tags": ["prime-document"] }
245
+ ```
246
+
247
+ Every field is optional; only the ones present are changed.
248
+
249
+ - `allow_self_approve` — owner/admin writes publish immediately instead of drafting.
250
+ - `prime_only_review` — in a governed nest, only **prime** documents need approval; everything else self-publishes. Off by default.
251
+ - `prime_tags` — tags that mark a document prime. Accepts an array or a comma-separated string; stored and returned normalized (no leading `#`, lowercased, deduped). See `STEWARDSHIP.md → Prime documents`.
252
+
253
+ Turning stewardship **off** wipes stewards and pending reviews and is owner-only; the other fields are non-destructive and never change the state of anything already published.
254
+
255
+ ### PATCH `/nests/:nestId/visibility` (admin)
256
+ ```json
257
+ { "visibility": "public", "acknowledge_public": true }
258
+ ```
259
+ - **`visibility`** is one of:
260
+ | value | who can read without being added |
261
+ |---|---|
262
+ | `private` | nobody — owner, collaborators and stewards only |
263
+ | `org` | any **authenticated** user of this deployment (a self-hosted install is one organization). Anonymous callers get nothing. |
264
+ | `public` | anyone, including anonymous callers |
265
+ `org` and `public` readers see **approved content only** — drafts and pending versions stay with collaborators and stewards.
266
+ Under `AUTH_MODE=open` every caller resolves to the anonymous user, so there is no "other authenticated user" for `org` to admit: on an open-mode deployment `org` behaves as `private`. Use it on a deployment with real logins.
267
+ - **Outside-org publish guardrail.** Setting `visibility: "public"` **requires** `acknowledge_public: true` (`org` does not — it stays inside the deployment, so it crosses no organizational boundary). Without it the request is rejected `409`:
268
+ ```json
269
+ {
270
+ "error": "Publishing this nest requires acknowledgement.",
271
+ "requires_acknowledgement": true,
272
+ "warning": "This makes the nest readable by anyone on the internet, outside your organization."
273
+ }
274
+ ```
275
+ Going back to `private` needs no acknowledgement.
276
+
277
+ ### PATCH `/nests/:nestId/reader-mode` (server-admin / superadmin only)
278
+ Configure **Public Docs Reader Mode** — a clean, read-only public docs view of the nest. Superadmin-gated via `isServerAdminUserId` (license admin OR `access.yaml` super_admins ∪ the DB-granted set; the same gate as `/nests/unsynced` and `/admin/*`). A plain nest-owner/admin who is not a superadmin gets `403`.
279
+ ```json
280
+ { "enabled": true, "home_node": "welcome" }
281
+ ```
282
+ - `enabled` (optional boolean) — toggle reader mode. When on, read-only callers (public/anonymous readers) get a stripped three-pane docs reader instead of the tabbed authoring UI; owners/editors/reviewers/admins keep the full UI.
283
+ - `home_node` (optional string | null) — the designated landing/hub node id. `null` (or a blank string) clears it, so the landing node resolves by convention (first node whose id/title is `welcome | index | readme | home | overview`, case-insensitive; else the synthesized Contents table). A non-string, non-null value is rejected `400`.
284
+ - At least one of `enabled` / `home_node` must be present (`400` otherwise).
285
+ - **`home_node` is a soft pointer, not validated at write time.** An id matching no node in the nest is stored as sent, and the reader falls back to the convention as if it were unset — so an API caller gets no signal that the designation missed. The reader matches on either the full node id (`nodes/welcome`) or its bare slug (`welcome`), case-insensitively; the UI picks the id from the nest's document list rather than accepting typed input, so it never sends one that resolves to nothing.
286
+
287
+ Rejections are the standard error shape:
288
+ ```json
289
+ { "error": "home_node must be a string or null" }
290
+ ```
291
+
292
+ **Reader mode enforces nothing.** It is presentation only: it hides authoring surfaces from callers who are already read-only. It grants no access, gates no route, and changes no permission — who can read the nest is still decided by visibility, collaborators and stewardship.
293
+
294
+ Response (200) echoes the persisted row:
295
+ ```json
296
+ { "reader_mode": 1, "reader_home_node": "welcome" }
297
+ ```
298
+ The two additive columns (`nests.reader_mode`, `nests.reader_home_node`) also surface on `GET /nests/:nestId` via the `SELECT *` nest payload.
299
+
300
+ ### GET `/nests/unsynced` (server-admin only outside open mode)
301
+ Lists folders under `DATA_ROOT` that aren't nests yet — typically dropped in by the starter CLI or copied in manually. Each entry is a sync candidate. Reflects server-side filesystem state, so in non-open mode only a server admin — the license admin or any superadmin (`access.yaml` ∪ granted; same resolution as `/admin/*`) — sees it (everyone else gets `403`).
302
+
303
+ Response (200):
304
+ ```json
305
+ {
306
+ "folders": [
307
+ {
308
+ "name": "nodes/architecture",
309
+ "label": "architecture",
310
+ "mdCount": 12,
311
+ "sizeBytes": 48230
312
+ }
313
+ ]
314
+ }
315
+ ```
316
+ - `name` — path relative to `DATA_ROOT` (slash-separated; pass back verbatim to `/sync`).
317
+ - `label` — leaf folder name (display only).
318
+ - `mdCount` / `sizeBytes` — totals across all `.md` files under the folder, recursive.
319
+
320
+ When a top-level dir has no markdown directly but holds subfolders that do (e.g. `nodes/architecture/`, `nodes/standards/`), each subfolder surfaces as its own card so users can sync them as separate nests.
321
+
322
+ ### POST `/nests/unsynced/sync` (server-admin only outside open mode)
323
+ Converts a sibling folder under `DATA_ROOT` into a real nest. The folder's markdown is imported (same flat-layout import as `POST /nests`), governance rows are seeded, and the source folder is removed afterwards so the same content can't be synced twice.
324
+
325
+ Body:
326
+ ```json
327
+ { "name": "nodes/architecture" }
328
+ ```
329
+ - `name` — required, as returned by `GET /nests/unsynced`. Travels in the body because leaf paths contain slashes that would split a path-param across route segments.
330
+
331
+ Response (201):
332
+ ```json
333
+ {
334
+ "nest": { "id": "...", "name": "architecture", "slug": "architecture", "...": "..." },
335
+ "documents": 12
336
+ }
337
+ ```
338
+ - Nest is named after the leaf segment (`nodes/architecture` → `architecture`). If that name (or its slug) collides for the user, a `" (n)"` suffix is appended so repeat syncs Just Work.
339
+ - `documents` — count of governance-registered docs.
340
+
341
+ Errors:
342
+ - `400 Invalid folder name` — empty, contains `\`, traversal segment, dot-prefixed segment, or `node_modules`.
343
+ - `400 Folder is not eligible for sync` — reserved top-level (e.g. `nests/`).
344
+ - `400 Folder has no markdown to sync` — empty folder.
345
+ - `404 Folder not found` — path doesn't exist under `DATA_ROOT`.
346
+ - `403 Only the server admin can sync folders` — non-admin in non-open mode.
347
+
348
+ Source-folder cleanup is best-effort. If `rmSync` fails (locked file etc.) the nest is still fully built; the orphan source dir just keeps showing up in `GET /nests/unsynced` until removed manually. Same admin gate exposed as MCP tools `context_unsynced_list` + `context_sync_folder` on any nest's `/mcp` endpoint.
349
+
350
+ ### DELETE `/nests/unsynced/sync` (server-admin only outside open mode)
351
+ Deletes an unsynced folder outright — for leftover files the user does **not** want as a nest. No nest is created. Same guards as sync (`assertSafeFolderName` + live-nest-storage-root check, so it can never delete a real nest or escape `DATA_ROOT` via traversal) and the same admin gate.
352
+
353
+ Body:
354
+ ```json
355
+ { "name": "nodes/architecture" }
356
+ ```
357
+ - `name` — required, as returned by `GET /nests/unsynced`. In the body (not a path-param) because leaf paths contain slashes.
358
+
359
+ Response (200):
360
+ ```json
361
+ { "ok": true }
362
+ ```
363
+
364
+ Errors:
365
+ - `400 name is required` — missing/blank name.
366
+ - `400` — unsafe/reserved/hidden name (traversal, `nests`, dot-prefixed, `node_modules`).
367
+ - `404 Folder not found` — path doesn't exist under `DATA_ROOT`.
368
+ - `403 Only the server admin can delete folders` — non-admin in non-open mode.
369
+
370
+ ---
371
+
372
+ ## 3. Collaborators (direct nest sharing)
373
+
374
+ Separate from stewardship — this grants raw nest access. Stewardship layers governance on top.
375
+
376
+ ### GET `/nests/:nestId/collaborators` (write+)
377
+ ```json
378
+ { "collaborators": [{ "id": "...", "user_id": "...", "email": "...", "permission": "read" }] }
379
+ ```
380
+ Write-tier (not read) so a public nest's read tier can't enumerate member emails.
381
+
382
+ ### POST `/nests/:nestId/collaborators` (write+)
383
+ ```json
384
+ { "email": "c@x.com", "permission": "read" }
385
+ ```
386
+ - `permission` ∈ `read` / `write` / `admin`.
387
+ - **Inviting no longer requires admin** — any **write+** collaborator can add people, but only **up to their own level** (escalation cap). A `write` collaborator can grant `read`/`write`; only an `admin`/owner can grant `admin`. Exceeding your level → `403`. Read-only collaborators can't invite.
388
+ - Auto-creates placeholder user (`is_invited = 1`) if email unknown.
389
+
390
+ ### PATCH `/nests/:nestId/collaborators/:collabId` (admin)
391
+ ```json
392
+ { "permission": "write" }
393
+ ```
394
+ Changing an existing collaborator's level stays admin-only.
395
+
396
+ ### DELETE `/nests/:nestId/collaborators/:collabId` (admin)
397
+ Removing a collaborator stays admin-only.
398
+
399
+ ### GET `/nests/:nestId/mentionable` (read+, members only)
400
+ ```json
401
+ { "people": [{ "email": "owner@x.com", "name": "Olive Owner" }] }
402
+ ```
403
+ Everyone who can reach the nest (owner, collaborators, shared-team members, stewards, grant holders) — the roster the `@mention` pickers offer and validate against. With `PEOPLE_SUGGEST_ALL_USERS` on, every registered account follows the roster; mentioning one of them from a write+ account adds them to the nest as a `read` collaborator (see `POST /comments`).
404
+
405
+ Read-tier, because commenting is read-tier and a read-only reviewer is the person most likely to tag someone. The property `GET /collaborators` protects with its write tier is kept by a narrower rule: **you may see the roster if you are on it.** A caller who reaches the nest only through its visibility (`public`, or `org`) is not on the roster → `403`. Write+ callers pass regardless (they can read the fuller roster anyway).
406
+
407
+ ### GET `/people/suggest?q=&limit=` (auth required)
408
+ ```json
409
+ { "people": [{ "email": "c@x.com", "name": "Carl Colleague", "source": "nest" }] }
410
+ ```
411
+ Server-wide, not nest-scoped: the people the caller **already works with** — anyone on a nest they own or collaborate on (its owner, collaborators, active stewards) and anyone on a team they own or belong to. `source` ∈ `nest` / `team` / `directory`. For a server admin the directory is every user; `GET /auth/teammates` remains the admin-only full roster.
412
+
413
+ Scope is deliberately narrow by default, because a single server may hold several unrelated organisations: `PEOPLE_SUGGEST_ALL_USERS` (off by default, Settings → General → People directory) widens it to every registered account, which is right for a single-company deployment and wrong for a shared one. A server admin always sees every account.
414
+
415
+ `q` filters on name or address (substring, case-insensitive); `limit` caps the list (default and max 20). Suggestions are a convenience for the add-a-person pickers — an address that appears nowhere here is still valid to submit (that is how you invite someone with no account yet), and the add itself re-checks permission and the escalation cap.
416
+
417
+ ---
418
+
419
+ ## 3a. Share grants (one document / one folder)
420
+
421
+ A **grant** gives one person access to a **single document** or **everything under one folder**, without making them a nest-wide collaborator. Access only — a grant is not stewardship and confers no approval rights.
422
+
423
+ Resolution: a grant covers a node when the node id equals `target` or sits under `target + "/"`. `write` outranks `read` when several grants overlap. A grantee with no nest-wide permission sees **only** the nodes their grants cover — `GET /nests/:id/nodes` is filtered to them.
424
+
425
+ **Discovery.** A grantee finds the nest by logging in: it is listed in `GET /nests`, and `GET /nests/:id` returns it, both with `permission: "read"`, `roles: ["viewer"]`, and `grant_only: true`. The list card is scoped to their slice, not the nest — `document_count` counts only the documents their grants cover, and `collaborator_*` / `steward_*` come back empty, so being shared one document never reveals how large the nest is or who else works in it. Grant-only nests deliberately do **not** enter the work inbox or `/stats`: those are governance surfaces over documents the grantee can't read.
426
+
427
+ The nest routes report a grantee as a `read` caller so the nest resolves instead of 404ing, but this buys no authority: rename, delete, import, and settings-write still require `owner`/`admin`, and the remaining nest paths (`/settings`, `/stewards`, `/collaborators`, `/review-queue`, `/feed`, …) are not on the grantee's middleware allow-list at all — they answer `404`. A grantee's reachable surface is exactly: `GET /nests/:id`, `GET /nests/:id/nodes` (filtered), the granted nodes themselves, and the read-query routes (`/query`, `/search`, `/context`, `/export`), each scoped to their grants. Note `resolveNestPermission` still returns `none` for them — that is what routes them into the per-node grant checks.
428
+
429
+ Managing grants is **owner / nest admin / server admin** (`canManageStewards` — same authority as the steward roster). Everyone else gets `403`.
430
+
431
+ ### GET `/nests/:nestId/grants`
432
+ ```json
433
+ { "count": 1, "grants": [{ "id": "...", "target_type": "document", "target": "nodes/win-loss-log", "user_id": "...", "email": "guest@x.com", "role": "read", "granted_by": "owner@x.com", "created_at": "..." }] }
434
+ ```
435
+ The whole nest's sharing roster, annotated with the grantee's email.
436
+
437
+ ### POST `/nests/:nestId/grants`
438
+ ```json
439
+ { "target_type": "document", "target": "nodes/win-loss-log", "email": "guest@x.com", "role": "read" }
440
+ ```
441
+ - `target_type` ∈ `document` / `folder`; `role` ∈ `read` / `write` (default `read`).
442
+ - `target` is a node id (`nodes/win-loss-log`) or a folder prefix (`nodes/gtm/deals`).
443
+ - A folder target must name a **subfolder**: a bare `nodes` root would match every node and silently share the whole nest → `400`.
444
+ - Re-granting the same `(target_type, target, user)` updates the role rather than duplicating.
445
+ - Auto-creates a placeholder user (`is_invited = 1`) if the email is unknown, same as collaborator invites.
446
+ - `201 { grant }`.
447
+
448
+ ### DELETE `/nests/:nestId/grants/:id`
449
+ `{ "deleted": true }`, or `404` if the grant isn't in this nest.
450
+
451
+ Moving a document carries its **document** grants to the new id; deleting it drops them. **Folder** grants pin to a path, so a moved document leaves one share and joins another. Grants do not travel in a nest export bundle.
452
+
453
+ Agent equivalent: the `context_grant` MCP tool takes the same four fields in one call (see §MCP and `/llms.txt`). In the UI: **Share this document** on a document's ⋯ menu, and the share icon on a folder row in the nest view.
454
+
455
+ ---
456
+
457
+ ## 3b. Teams (group sharing)
458
+
459
+ A **team** is a reusable, owner-managed group of users. Each member carries a
460
+ **role** (`admin` / `editor` / `viewer`) set on the membership and applied
461
+ uniformly on every nest the team is shared to. Sharing a team onto a nest
462
+ records a `{teamId, teamName}` ref on `nests.shared_teams` (no join table);
463
+ every member then resolves to their own role there, combined highest-wins with
464
+ any direct collaborator / steward grant.
465
+
466
+ Member role → access mapping:
467
+
468
+ | role | nest permission | governance role |
469
+ |------|-----------------|-----------------|
470
+ | `viewer` | `read` | viewer |
471
+ | `editor` | `write` | editor |
472
+ | `admin` | `admin` | admin |
473
+
474
+ Sharing a team whose members carry a governance role (`viewer`/`editor`) turns
475
+ `stewardship_enabled` on for the nest (parity with assigning an individual
476
+ steward). An `admin`-only team is collaborator-style and does not.
477
+
478
+ ### Team management (global)
479
+
480
+ Auth is the shared session/key auth. Management is gated by the caller's role on
481
+ the team (the server/license admin may manage any team): **add people** =
482
+ editor/admin members + owner (escalation-capped — can't grant above your own
483
+ role); **rename / change-role / remove** = admin members + owner; **delete** =
484
+ owner only. Unregistered member emails mint a placeholder user (`is_invited = 1`),
485
+ same as collaborator/steward invites.
486
+
487
+ - **POST `/teams`** — `{ "name": "Platform" }` → `201 { team }`. Any user may
488
+ create a team (they become its owner).
489
+ - **GET `/teams`** — teams the caller owns **or is a member of** → `{ teams: [...] }`.
490
+ Each member is enriched with `registered` (real account vs invited placeholder)
491
+ and the team with `owner_email`.
492
+ - **GET `/teams/:teamId`** — owner, a member, or the server admin may view →
493
+ `{ team: { id, name, owner_id, owner_email, members: [{ userId, email, role, registered }], ... } }`.
494
+ - **PATCH `/teams/:teamId`** — `{ "name": "..." }` rename (admin/owner). Also
495
+ refreshes the cached `teamName` in every nest that shares this team.
496
+ - **DELETE `/teams/:teamId`** — delete (owner/server-admin); strips the team's
497
+ ref from every nest's `shared_teams`.
498
+ - **POST `/teams/:teamId/members`** — `{ "email"|"user_id", "role" }` add a
499
+ member (`role` ∈ `admin`/`editor`/`viewer`) → `201 { team }`. Editor/admin
500
+ members + owner, capped to the caller's own role. Duplicate member → `409`;
501
+ invalid role → `400`; above your level → `403`.
502
+ - **PATCH `/teams/:teamId/members/:userId`** — `{ "role": "..." }` change role
503
+ (admin/owner, capped).
504
+ - **DELETE `/teams/:teamId/members/:userId`** — remove a member (admin/owner);
505
+ their access to every shared nest is revoked immediately (no per-user rows).
506
+
507
+ A team-shared nest also appears on each member's dashboard (`GET /nests`) with
508
+ their role, so they can open it and act per their role.
509
+
510
+ ### PromptOwl team import (`/teams/promptowl/*`)
511
+
512
+ Copy the teams the caller owns in PromptOwl into this server. Both routes are
513
+ capped at 10 requests per IP per 15 minutes.
514
+
515
+ **Only a PromptOwl sign-in can import.** Signing in with PromptOwl parks the
516
+ device token in an httpOnly cookie (`cnst_po_token`, `Path=/teams`, lifetime of
517
+ the session), and these routes read it **from there and nowhere else** — a token
518
+ in the request body is ignored, so a user who signed in another way (SSO, OIDC,
519
+ password) cannot import by supplying one. Hiding the UI is the other half of
520
+ this rule, not the enforcement.
521
+
522
+ The token is **never written to the database**: PromptOwl hands it over exactly
523
+ once, and a self-hosted server holding live credentials for someone else's SaaS
524
+ is a far worse liability than a cookie that dies with the session. `Path=/teams`
525
+ means the browser only attaches it to these routes. It is scoped upstream to
526
+ `user:read` + `teams:read`, so it cannot act as the user in PromptOwl (no agent
527
+ chat, no credit spend) even if it leaks.
528
+
529
+ Sign-in also sets `cnst_po_session=1` — a readable, secret-free marker so the
530
+ SPA knows whether to offer the PromptOwl Teams tab. Both cookies are cleared on
531
+ logout.
532
+
533
+ **`428 Precondition Required`** with `{ code: "promptowl_connect_required" }`
534
+ means "no PromptOwl session — sign in with PromptOwl". It is deliberately **not**
535
+ `401`: the caller is authenticated here, and a `401` would read to the SPA as an
536
+ expired session and bounce them to the login screen. The same 428 is returned
537
+ when PromptOwl rejects the token (revoked upstream), and the cookie is cleared
538
+ so a dead token isn't replayed forever.
539
+
540
+ An imported team carries `source: "promptowl"` and `external_id` (its PromptOwl
541
+ id); a team created here has `null` for both. `(owner_id, source, external_id)`
542
+ is the import key — a re-import updates that same team rather than duplicating
543
+ it, and survives a rename on either side. Two users importing the same PromptOwl
544
+ team each get their own local team.
545
+
546
+ - **GET `/teams/promptowl`** — `{ teams: [{ id, name, owned, your_role, source,
547
+ enterprise, updated_at, members: [{ email, role }] }] }`. Read-only. Returns
548
+ every team the caller owns **or belongs to** in PromptOwl; `your_role` is
549
+ their role on it there. No PromptOwl session → `428`; unreachable PromptOwl →
550
+ `400`.
551
+ - **POST `/teams/promptowl/sync`** — `{ "team_ids": [...] }` →
552
+ `{ results: [{ team_id, external_id, name, created, added, updated, removed }] }`,
553
+ where `added`/`updated`/`removed` are member emails. A `team_id` the caller has
554
+ nothing to do with upstream → `400` (unrelated and nonexistent teams are
555
+ refused identically, so the error can't be used to probe which ids exist).
556
+
557
+ **Importing requires `Owner` or `Editor` upstream** → otherwise `403`, and the
558
+ whole batch is refused before any writes. The local copy is owned by the
559
+ importer — free rein to rename it, rewrite its roster, and share it onto a
560
+ nest. A view-only member of the upstream team holds none of that and must not
561
+ gain it by copying the team here; that's the same escalation
562
+ `shareTeamWithNest` already refuses. PromptOwl's `Viewer`, its default `User`,
563
+ and any unrecognized role all count as no authority.
564
+
565
+ **PromptOwl wins.** The local roster is reconciled to match: roles are reset
566
+ and members no longer in the PromptOwl team are **removed**, including ones
567
+ added here by hand. Roles map `Owner→admin`, `Editor→editor`, `Viewer→viewer`,
568
+ and `User`→`viewer` (as does any unrecognized role — an unknown role must never
569
+ widen access). The importer is the local team's owner and is not written into
570
+ the roster. Unregistered member emails mint placeholders exactly as
571
+ `POST /teams/:teamId/members` does.
572
+
573
+ ### Nest team-share (`/nests/:nestId/teams`)
574
+
575
+ - **GET** `(write+)` — teams shared onto the nest, enriched with members:
576
+ `{ teams: [{ teamId, teamName, members: [{ userId, email, role }] }] }`.
577
+ Write-tier (like the collaborator roster) so a read tier can't enumerate emails.
578
+ - **POST** `(admin)` — `{ "team_id": "..." }` share a team → `201 { teams }`.
579
+ Already shared → `409`. The caller must also be **editor+ on the team**
580
+ (editor/admin/owner, or server admin); a view-only member sharing → `403`, so
581
+ a viewer can't project a whole team onto a nest they own.
582
+ - **DELETE `/nests/:nestId/teams/:teamId`** `(admin)` — unshare → `{ teams }`.
583
+
584
+ ---
585
+
586
+ ## 4. Nodes (documents)
587
+
588
+ `nodeId` = relative path without `.md`, e.g. `nodes/api-design`.
589
+
590
+ ### GET `/nests/:nestId/nodes`
591
+ Lists all readable docs.
592
+ - When `stewardship_enabled = true`, list is filtered by `filterAccessible` — non-steward users only see what they're assigned to.
593
+
594
+ Optional query params, applied before the per-node enrichment:
595
+
596
+ | Param | Notes |
597
+ |---|---|
598
+ | `type` | Node type. A doc with no `type:` counts as `document`. |
599
+ | `tag` | Leading `#` optional, case-insensitive. |
600
+ | `status` | `draft` \| `pending_review` \| `approved` \| `published` \| `rejected`; aliases normalized. Retired docs stay in this listing regardless — here they are unpublished docs their stewards may still act on. |
601
+ | `approved_only` | `1`/`true` — only docs with an approved version, each served at that version rather than the live file. The view a public nest gives a stranger, so an owner can preview what going public would expose. |
602
+ | `limit` | Max nodes to return. A non-numeric value is ignored, not an error. |
603
+ | `folder` | Present (even as `""`) → that one folder level only, for a lazily-expanded document tree. Absent → the whole nest. |
604
+ | `content` | `1`/`true` — include document bodies. **Off by default:** the listing is served from the nest's document index, which holds metadata only, so bodies cost a vault crawl and are opt-in. |
605
+ ```json
606
+ {
607
+ "count": 1,
608
+ "nodes": [{
609
+ "id": "nodes/foo",
610
+ "title": "Foo",
611
+ "type": "document",
612
+ "tags": ["#api"],
613
+ "status": "draft" | "pending_review" | "approved" | "rejected",
614
+ "version": 1,
615
+ "author": "creator@x.com",
616
+ "collaborators": ["editor@x.com", "grantee@x.com"],
617
+ "created_at": "...",
618
+ "updated_at": "...",
619
+ "content": "# md body — only with ?content=1",
620
+ "schedule": "0 6 * * *"
621
+ }]
622
+ }
623
+ ```
624
+ - `schedule` is present only on runnable nodes (`type` `agent`/`skill`); omitted otherwise.
625
+ - `author` = the doc's creator (frontmatter author, else the first version's author); `null` for old imports with neither. `collaborators` = distinct version authors ∪ document-scope grant holders, minus the author, sorted. Both also appear on the single-node GET below.
626
+ - Public readers (callers who reach a `visibility: public` or `visibility: org` nest without being its owner or a collaborator) do **not** receive `collaborators` — the field is omitted, and `author` falls back to the frontmatter value only, never to a version author. The roster is treated as private to the nest's members, like draft content.
627
+
628
+ ### POST `/nests/:nestId/nodes`
629
+ ```json
630
+ {
631
+ "title": "Foo",
632
+ "content": "# Foo\n\nbody...",
633
+ "tags": ["api", "v2"],
634
+ "type": "document",
635
+ "scope": "team",
636
+ "status": "draft",
637
+ "folder": "gtm/deals",
638
+ "schedule": "0 6 * * *"
639
+ }
640
+ ```
641
+ - Slug derived from title → `nodes/<slug>`.
642
+ - Optional `folder` (string) nests the doc → `nodes/<folder>/<slug>`. Each `/`-separated segment is slugified independently (so `../` or absolute paths can't escape the vault); empty segments drop. A leading `nodes/` segment is stripped, so `nodes/gtm/deals` and `gtm/deals` mean the same folder. Max 8 levels deep; each segment (and the title slug) max 100 chars — otherwise `400`.
643
+ - `409` when the derived id is already taken (same folder + same title). The existing document is left untouched — update it, or pick another folder or title. The same title in a *different* folder is still allowed here; MCP `context_create` is stricter and refuses it (see [§9 MCP](#9-mcp)).
644
+ - Optional `schedule` (free-form cron/interval string, opaque to the server) — **only valid when `type` is `agent` or `skill`**; sent on any other type → `400`. Stored in frontmatter metadata and echoed back on every node response. See [§9 `GET /runnables`](#get-nestsnestidrunnables-read).
645
+ - Optional `prime` (boolean) flags the document as **prime** — it then always requires steward approval, whoever writes it. Owner/admin only; any other caller sending the field gets `403`. Omit to inherit from the nest's `prime_tags`; `false` exempts this document from an otherwise-prime tag. Echoed back on every node response as `prime` (absent when inherited). See `STEWARDSHIP.md → Prime documents`.
646
+ - If stewardship enabled → status defaults `draft`. Else `approved`.
647
+ - Auto-syncs tag index for steward resolution.
648
+
649
+ Response (201): `{ "node": { ... }, "stewards": [...] }`.
650
+
651
+ ### GET `/nests/:nestId/nodes/:nodeId`
652
+ Full node content. 403 if no steward access (when stewardship on).
653
+ Add `?format=markdown` to receive `text/markdown` (frontmatter + body) instead
654
+ of JSON — see [§8 export](#get-nestsnestidexportformatmarkdown--llm-pointed-plain-text-view).
655
+
656
+ **Direct raw fetch** — `?format=raw` returns the node **body** as plain text
657
+ (no JSON envelope, no synthesized frontmatter):
658
+
659
+ | Param / header | Effect |
660
+ |---|---|
661
+ | `?format=raw` | Raw node body as text; Content-Type per node type (table below). |
662
+ | `Accept: text/markdown` (header) | Same as `?format=raw` when no `format` param is present. An explicit `format` param always wins (`?format=markdown` keeps the frontmatter+body export; any other `format` value keeps JSON). Exact media-type match only — `text/*` / `*/*` do not trigger it. Matching is presence-based and deliberately ignores q-values: `text/markdown;q=0.1` still triggers raw. |
663
+ | `?frontmatter=1` | Only meaningful with raw: prepends the node's YAML frontmatter block exactly as stored, above the body. |
664
+
665
+ Content-Type by node `type`:
666
+
667
+ | Node type | Content-Type |
668
+ |---|---|
669
+ | `document`, `agent`, `skill` (and any other/unset type) | `text/markdown; charset=utf-8` |
670
+ | `artifact` | `text/html; charset=utf-8` |
671
+ | `tool`, `table` | `text/plain; charset=utf-8` |
672
+
673
+ Access is identical to the JSON GET — same middleware, grants, and
674
+ public-reader semantics. Public readers get the **approved snapshot** body
675
+ (and, with `frontmatter=1`, that snapshot's stored frontmatter); callers
676
+ without read access get the same 403/404 JSON error as the JSON GET, never
677
+ body bytes. Without `format`/`Accept` the response is unchanged JSON.
678
+
679
+ ### POST `/nests/:nestId/nodes/:nodeId/revert`
680
+ Restore an earlier version by writing its content as a **new** version (history is preserved).
681
+ ```json
682
+ { "targetVersion": 3 }
683
+ ```
684
+ Response: `{ "ok": true, "version": <new>, "node": { ... } }`. `400` without a valid `targetVersion`; `404` if that version doesn't exist. Only **sealed** versions can be reverted to — a draft revision is not in the version chain (see below).
685
+
686
+ ### POST `/nests/:nestId/nodes/:nodeId/discard`
687
+
688
+ Throw away the unreviewed work on a document. No body.
689
+
690
+ Response: `{ "ok": true, "deleted": <bool>, "version": <sealed|null> }`.
691
+
692
+ Two outcomes, and which one you get depends on the document:
693
+ - It was published before → the file goes back to that version and the draft rows above it are dropped. `deleted: false`, `version` names the version restored.
694
+ - It was **never** published → there is nothing to fall back to, so the document is deleted, exactly as `DELETE /nodes/:nodeId` would. `deleted: true`.
695
+
696
+ Costs no version either way. A draft save is never appended to the engine's history chain — that chain is append-only and hash-linked, so anything recorded in it can never be taken back. Unreviewed work lives in the file plus one governance row; a version is sealed when the draft is **submitted for review**, and again when it is published. That is what makes discarding cheap, and why `ctx verify` is unaffected by it.
697
+
698
+ `423` while a review is pending — discarding would pull the document out from under the reviewer. Reject the review first. `400` if the sealed version can no longer be reconstructed from the chain (delete the document instead).
699
+
700
+ ### PATCH `/nests/:nestId/nodes/:nodeId`
701
+ Headers (optional): `X-Base-Version: 3` — server returns 409 on conflict.
702
+ ```json
703
+ {
704
+ "title": "...",
705
+ "content": "...",
706
+ "tags": ["..."],
707
+ "changeNote": "fixed typo",
708
+ "schedule": "0 7 * * *"
709
+ }
710
+ ```
711
+ Requires nest write permission.
712
+ - `schedule` — runnable types (`agent`/`skill`) only; sent for any other type → `400`. Send `""` to clear it.
713
+ - `type` — re-type the node in place (the editor's "Turn into…"): `document`, `artifact`, `table`, `agent`, `skill`, `tool`, or any other engine type. Same gate as create — the artifact/table feature flags and the accepted set (`400` otherwise). The body is kept as-is. Converting to `skill` defaults the trigger from the title; converting away from a runnable type drops its `schedule`.
714
+ - `prime` — owner/admin only (`403` otherwise). `true`/`false` set the document-scope flag, `null` clears it back to tag/nest inheritance. An edit that makes the document prime is itself gated, so it lands as a draft awaiting approval.
715
+
716
+ ### DELETE `/nests/:nestId/nodes/:nodeId`
717
+ Write permission. Hidden in UI for reviewer/viewer; server still enforces.
718
+
719
+ A node with a pending review is frozen — `423` — for callers who can't clear that review. Approvers (nest owner, admin, assigned reviewer) go through: they can reject and then delete, so the lock only cost them a round trip. Same rule on `POST /nodes/:nodeId/move`; editing under review is unchanged (still author-only).
720
+
721
+ Purges everything keyed to the node id — version history, review requests, the approved-version pin, tag index, annotations and their comments, review comments, watchers, schedules, trigger hooks, workflow edges, document-scoped stewards and document grants — and leaves a deletion tombstone for `GET /changes`. Node ids derive from titles, so a later document can land on the same id; it must inherit none of the old one's readers, reviewers or history. Run traces are kept: they record what happened, not what exists. `POST /nests/:nestId/nodes/:nodeId/move` carries that same set to the new id instead of dropping it (folder grants excepted — they cover a path prefix, so a moved doc leaves one share and joins another).
722
+
723
+ ### POST `/nests/:nestId/assets`
724
+ Multipart upload (field `file`) of an image or video referenced from documents. Write permission.
725
+
726
+ Supported types and per-type size caps:
727
+
728
+ | Type | Extensions | Max size |
729
+ |------|-----------|----------|
730
+ | Image | `png`, `jpg`/`jpeg`, `gif`, `webp` | 10 MB |
731
+ | Video | `mp4`, `webm` | 100 MB |
732
+
733
+ SVG is deliberately not accepted (stored-XSS surface). Returns `201` with `{ file, url, markdown }` — `markdown` is a ready-to-paste reference: `![alt](url)` for images, the bare asset URL for video (the read view renders same-origin asset video URLs as a player).
734
+
735
+ ### GET `/nests/:nestId/assets/:file`
736
+ Serves the asset bytes with the correct content-type and immutable caching. Read permission. Only server-minted `<uuid>.<ext>` names are accepted.
737
+
738
+ ### Watchers — GET/POST/DELETE `/nests/:nestId/nodes/:nodeId/watchers`
739
+ "Tag a person to this node." Watchers of a document are notified when it changes or lands for review; watchers of an agent hear when its output does. `POST` body `{ email }` (write); `DELETE …/watchers?email=` (write). All three return `{ "watchers": [ … ] }`. Watchers follow a `move` and are purged by a `DELETE` of the node.
740
+
741
+ ---
742
+
743
+ ## 5. Stewardship
744
+
745
+ ### GET `/nests/:nestId/stewards`
746
+ ```json
747
+ { "stewards": [{
748
+ "id": "...",
749
+ "userEmail": "rev@x.com",
750
+ "userId": "...",
751
+ "scope": "document" | "tag" | "nest",
752
+ "nodePattern": "nodes/foo" | "<nestId>" | null,
753
+ "tagName": "api" | null,
754
+ "role": "editor" | "reviewer" | "viewer"
755
+ }] }
756
+ ```
757
+
758
+ ### POST `/nests/:nestId/stewards`
759
+ **Two shapes accepted.**
760
+
761
+ **Legacy single-user (what the UI sends):**
762
+ ```json
763
+ {
764
+ "scope": "document",
765
+ "email": "rev@x.com",
766
+ "role": "reviewer",
767
+ "nodePattern": "nodes/foo"
768
+ }
769
+ ```
770
+
771
+ Field selection by scope:
772
+ | scope | required field | meaning |
773
+ |---|---|---|
774
+ | `document` | `nodePattern` | exact node id |
775
+ | `tag` | `tagName` | bare tag, no `#` |
776
+ | `nest` | — | covers every node in the nest |
777
+
778
+ **Multi-user shape:**
779
+ ```json
780
+ {
781
+ "scope": "tag",
782
+ "tagName": "api",
783
+ "users": [
784
+ { "email": "r1@x.com", "role": "reviewer" },
785
+ { "email": "r2@x.com", "role": "editor" }
786
+ ]
787
+ }
788
+ ```
789
+ For document scope use `documentId`; for nest no target. Sending `scope: "folder"` returns **400** — folder scope was removed.
790
+
791
+ **Side effects on POST:**
792
+ 1. Auto-flips `nests.stewardship_enabled = 1` on the nest.
793
+ 2. Auto-creates `users` row with `is_invited = 1` if email unknown.
794
+ 3. Auto-inserts `nest_collaborators`: `editor → write`, `reviewer | viewer → read`.
795
+ 4. Idempotent — duplicate `(nest, scope, target, email)` returns the existing row.
796
+
797
+ Response (201): `{ "stewards": [...] }` (multi) or `{ "steward": {...} }` (legacy single).
798
+
799
+ ### DELETE `/nests/:nestId/stewards/:stewardId`
800
+ Soft-removes (`is_active = 0`).
801
+
802
+ ### POST `/nests/:nestId/stewards/sync`
803
+ Re-imports stewards from `<nest>/.context/stewards.yaml` if present.
804
+ Response: `{ "synced": N }`.
805
+
806
+ ### GET `/nests/:nestId/nodes/:nodeId/stewards`
807
+ Resolved stewards for one node (priority `document(1) > tag(2) > nest(3)`).
808
+ ```json
809
+ {
810
+ "nodeId": "nodes/foo",
811
+ "stewards": [{
812
+ "email": "rev@x.com",
813
+ "role": "reviewer",
814
+ "scope": "document",
815
+ "source": "document: nodes/foo",
816
+ "priority": 1
817
+ }],
818
+ "fallbackToOwner": false,
819
+ "ownerEmail": null
820
+ }
821
+ ```
822
+
823
+ ### GET `/nests/:nestId/nodes/:nodeId/can-access`
824
+ `{ "allowed": true, "reason": "...", "role": "reviewer" | null }`
825
+
826
+ ### GET `/nests/:nestId/nodes/:nodeId/can-approve`
827
+ Same shape. Allowed iff the caller has a matched steward row with `role: reviewer` covering this node.
828
+
829
+ ### GET `/nests/:nestId/nodes/:nodeId/can-edit`
830
+ Same shape. Reflects nest collaborator permission OR steward `editor` role.
831
+
832
+ ---
833
+
834
+ ## 6. Review workflow
835
+
836
+ ### POST `/nests/:nestId/nodes/:nodeId/submit-review`
837
+ ```json
838
+ { "note": "ready for review", "priority": "low" | "normal" | "high" | "urgent" }
839
+ ```
840
+ Sets node status → `pending_review`. UI locks editing.
841
+
842
+ ### POST `/nests/:nestId/nodes/:nodeId/approve`
843
+ ```json
844
+ { "note": "lgtm", "override": false }
845
+ ```
846
+ - Server checks caller has a steward row covering this node with `role = 'reviewer'`. **403 `Insufficient permissions`** otherwise.
847
+ - Nest middleware allows `read`-perm collaborators through this path; per-row stewardship enforces real authorization.
848
+ - `override: true` only honored for the super-admin license owner.
849
+
850
+ Response: `{ "review": { "id": "...", "status": "approved", ... } }`.
851
+
852
+ ### POST `/nests/:nestId/nodes/:nodeId/reject`
853
+ ```json
854
+ { "note": "needs change X" }
855
+ ```
856
+ `note` required. Same permission gating as approve (`role = 'reviewer'`).
857
+
858
+ ### POST `/nests/:nestId/nodes/:nodeId/cancel-review`
859
+ Cancels the pending review. Returns node to `draft`.
860
+
861
+ Allowed for the person who submitted it (withdrawing your own submission) and
862
+ for anyone with approve rights on the node. Anyone else gets 403 — read access
863
+ to the nest alone is not enough.
864
+
865
+ ### GET `/nests/:nestId/external-edits`
866
+ Query: `?refresh=true` to rescan the disk first; `?count=1` to get the total only.
867
+
868
+ Pending external (on-disk) edits awaiting approval, newest first. The list is computed by scanning every document's suggestions, so `?count=1` saves bytes (`{ "total": n }`), not server time — the UI uses it for the tab badge.
869
+ ```json
870
+ { "entries": [{ "suggestion_id": "...", "document_id": "nodes/foo", "source": "out-of-band-edit", "detected_at": "...", "actor": "..." }], "total": 1 }
871
+ ```
872
+
873
+ ### GET `/nests/:nestId/review-queue`
874
+ Query: `?status=pending&limit=50&offset=0`
875
+
876
+ Returns ALL reviews for the nest (server does not gate by reviewer scope).
877
+ ```json
878
+ {
879
+ "requests": [{
880
+ "id": "...",
881
+ "nodeId": "nodes/foo",
882
+ "version": 3,
883
+ "requestedBy": "author@x.com",
884
+ "requestedAt": "...",
885
+ "status": "pending",
886
+ "priority": "normal",
887
+ "requestNote": "..."
888
+ }],
889
+ "total": 1
890
+ }
891
+ ```
892
+
893
+ **UI filtering** (recent change) — the React layer hides:
894
+ - Items the caller submitted (separation of duties).
895
+ - Items outside the caller's steward scope. **No admin/owner bypass.**
896
+
897
+ So Postman will see more rows than the UI; that's expected.
898
+
899
+ ### POST `/nests/:nestId/review-queue/bulk`
900
+ Body: `{ "nodeIds": ["nodes/a", "nodes/b"], "action": "approve" | "reject", "note": "..." }`
901
+
902
+ One decision applied to several pending documents. Each item runs through the
903
+ same `approve` / `reject` as the per-document routes, so permission resolution,
904
+ state validation, history and notifications are identical — this is a wrapper,
905
+ not a second governance path. A rejection still requires `note`, and there is no
906
+ override: forcing past a refusal stays a per-document decision.
907
+
908
+ Partial success is the normal outcome, so the call returns `200` with a per-item
909
+ report rather than failing the batch:
910
+
911
+ ```json
912
+ {
913
+ "action": "approve",
914
+ "succeeded": 2,
915
+ "failed": 1,
916
+ "results": [
917
+ { "nodeId": "nodes/a", "ok": true },
918
+ { "nodeId": "nodes/b", "ok": true },
919
+ { "nodeId": "nodes/c", "ok": false, "error": "Approvals go to assigned reviewers…" }
920
+ ]
921
+ }
922
+ ```
923
+
924
+ Duplicate ids collapse. Up to **50** ids per call; an empty or oversized
925
+ selection is refused up front (`400`) rather than silently truncated.
926
+
927
+ ### GET `/nests/:nestId/nodes/:nodeId/reviews`
928
+ Full review history for a node.
929
+
930
+ ### POST `/nests/:nestId/nodes/:nodeId/request-deletion`
931
+ Body: `{ "reason": "superseded by the new runbook" }` (required).
932
+
933
+ **Editor permission.** Asking for a deletion is an editor's act: a viewer of any kind — a `read` collaborator, a viewer steward, someone holding a `read` share grant — gets `403`. What counts as an editor is the same three paths that let someone change the document: nest-wide `write`, an editor steward row covering it, or a `write` share grant on it. Deleting outright stays higher still (owner/admin, or the document's own author), so an editor who spots dead content flags it rather than removing it. `404` comes first for a node the caller can't read or that doesn't exist, so the gate is never an existence oracle.
934
+
935
+ A flagged node is **not** locked or under review; it stays editable and never appears in the review queue. One pending flag per node (`409` on a second). Returns `201 { request }`.
936
+
937
+ `GET /nests/:nestId/nodes/:nodeId` carries `deletionRequest` — the node's latest flag (`{ status, requestedBy, reason, resolvedBy?, resolutionNote?, resolvedAt? }`) or `null`. `pending` lets a client badge the document and drop its request affordance rather than discovering the `409`; `declined` carries the resolver's note, which is how the person who asked learns the verdict.
938
+
939
+ ### POST `/nests/:nestId/request-deletion`
940
+ Body: `{ "targetType": "folder" | "nest" | "nest_archive", "target": "api/limits", "reason": "..." }` (`target` ignored for both nest targets).
941
+
942
+ `nest_archive` asks for the nest to be **archived** rather than destroyed — same table, same queue, same reviewers and the same decline/withdraw paths as any other request; only what honouring it does differs. The two nest targets are independent pending rows, so one of each can be open at once.
943
+
944
+ Editor permission, same as the per-document route — with one difference: a steward editor needs **nest** scope here, since a row scoped to one document is no mandate over a folder or the whole nest. A folder target is normalized (`/nodes/api/limits/` → `api/limits`) so two people flagging the same folder collide on one pending request rather than raising two; `404` if no documents live under it. One pending flag per (nest, target type, target).
945
+
946
+ ### POST `/nests/:nestId/delete-folder`
947
+ Body: `{ "folder": "api/limits" }`. **Owner/admin only** — a folder delete is many document deletes at once, so it sits at the nest-management tier, not `write`. Deletes every document under the prefix (sub-folders included) through the normal per-node path, so each gets its review-lock check, row purge and tombstone. Returns `{ deleted, nodeIds }`. Non-admins use the request flow above.
948
+
949
+ Folders are not stored objects — they exist only as segments of the node ids beneath them, so deleting a folder *is* deleting its documents.
950
+
951
+ ### GET `/nests/:nestId/deletion-requests`
952
+ Query: `?status=pending|declined|all` (defaults to `pending`). `all` returns both states newest-first — one call for a client that shows the queue and badges declined nodes.
953
+
954
+ ```json
955
+ {
956
+ "requests": [{
957
+ "id": "...",
958
+ "nodeId": "nodes/foo",
959
+ "title": "Foo",
960
+ "requestedBy": "reader@x.com",
961
+ "reason": "superseded",
962
+ "status": "pending",
963
+ "createdAt": "..."
964
+ }],
965
+ "total": 1
966
+ }
967
+ ```
968
+
969
+ ### POST `/nests/:nestId/deletion-requests/:id/delete`
970
+ Honours the request — write permission, the same tier `DELETE /nodes/:nodeId` requires (including its `423` review lock). Branches on the request's `targetType`: a document goes through `deleteNode`, a folder through the bulk delete above, a nest through `deleteNest`, and `nest_archive` through `archiveNest` (nothing is deleted; the nest can be restored).
971
+
972
+ Both **nest** targets additionally require owner/admin: acting on a whole nest is the owner's call (`DELETE /nests/:id` and `POST /nests/:id/archive` enforce that), and honouring a request must not be a way around it — a write-tier collaborator gets `403`.
973
+
974
+ An honoured request always leaves the queue: a document's row is purged with its other node-keyed rows, a nest's cascades with the nest, and a folder's — which is neither — is dropped explicitly.
975
+
976
+ Raising a request notifies the nest owner + owner/admin collaborators: a `/me/work` inbox row, the `deletion_requested` connector event (Slack/Teams/webhook), and a direct email. Resolving it — either way — notifies the requester the same way (`deletion_deleted` / `deletion_declined`). All delivery is best-effort; a notification failure never fails the action.
977
+
978
+ `GET /me/work` returns these alongside reviews as items with `type: "deletion"`, carrying `reason` and `request_id`. They land in `items` only for nest owners/admins (the tier that can delete) and in `waiting` for whoever raised them.
979
+
980
+ ### POST `/nests/:nestId/deletion-requests/:id/decline`
981
+ Body: `{ "note": "still referenced by onboarding" }` (required). Write permission. Keeps the node and the row, recording who declined and why. `404` once the request is no longer pending.
982
+
983
+ ### GET `/nests/:nestId/feed`
984
+ Query: `?filter=all|changes|comments&limit=50&offset=0` (filter defaults to `all`, limit caps at 200, offset caps at 10 000)
985
+
986
+ Nest **activity feed** — pending change submissions (the exact rows
987
+ `/review-queue` shows, every field intact, plus the caller's `canReview`)
988
+ interleaved with annotation activity (new comment threads AND replies),
989
+ newest first. Additive sibling of `/review-queue`; that endpoint and the MCP
990
+ `context_review_queue` tool are unchanged.
991
+
992
+ Comment items are access-filtered to documents the caller can read (same
993
+ resolution as node listing: public readers see approved docs only; grants and
994
+ steward scope honored). Change items mirror the queue (not access-filtered).
995
+
996
+ ```json
997
+ {
998
+ "items": [
999
+ {
1000
+ "type": "comment",
1001
+ "id": "<commentId>",
1002
+ "threadId": "<threadId>",
1003
+ "nodeId": "nodes/foo",
1004
+ "title": "Foo",
1005
+ "author": "reviewer@x.com",
1006
+ "snippet": "First ~200 chars of the comment…",
1007
+ "createdAt": "2026-07-14 11:00:00",
1008
+ "resolved": false,
1009
+ "isReply": true
1010
+ },
1011
+ {
1012
+ "type": "change",
1013
+ "id": "<reviewRequestId>",
1014
+ "nestId": "...",
1015
+ "nodeId": "nodes/foo",
1016
+ "title": "Foo",
1017
+ "version": 3,
1018
+ "requestedBy": "author@x.com",
1019
+ "requestedAt": "2026-07-14 10:00:00",
1020
+ "requestNote": "...",
1021
+ "status": "pending",
1022
+ "priority": "normal",
1023
+ "canReview": true,
1024
+ "createdAt": "2026-07-14 10:00:00"
1025
+ }
1026
+ ],
1027
+ "total": 2,
1028
+ "counts": { "changes": 1, "comments": 1 },
1029
+ "filter": "all",
1030
+ "limit": 50,
1031
+ "offset": 0
1032
+ }
1033
+ ```
1034
+
1035
+ `counts` always carries both per-type totals regardless of `filter`; the UI
1036
+ tab badge binds to `counts.changes` (actionable items), not the feed length.
1037
+ The UI deep-links a comment item to its document with the thread focused via
1038
+ `?nest=<id>&doc=<nodeId>&thread=<threadId>`.
1039
+
1040
+ ### Notification rows (`GET /me/work` → `notifications`, `GET /me/notifications`)
1041
+
1042
+ Each row carries `nest_id`, `nest_name` (resolved in the same read, so the inbox navigates without fetching the nest list), `kind`, `message`, `created_at`, `read_at`, and `node_id` when the row is about a document (review verdicts, document deletion flags, mentions); nest-level rows have `node_id: null`.
1043
+
1044
+ ### GET `/me/drafts`
1045
+
1046
+ Query: `?limit=25&offset=0&q=&sort=updated|title` (limit clamps to 1–100, offset to ≥ 0)
1047
+
1048
+ `q` matches the title, the nest name and the node id, case-insensitively. `sort` defaults to `updated` (newest first). A title lives on disk rather than in SQL, so `q` and `sort=title` read the whole matching set (capped at 500 rows) and page it in memory — on those two, `total` counts what survives the filter and is exact.
1049
+
1050
+ Every document **you** left unsubmitted, across every nest you can see. A draft
1051
+ that was never submitted has no `review_requests` row, so it appears in no
1052
+ review queue and no `/me/work` bucket — this is the only surface that finds it
1053
+ again. Backs the dashboard's "My Drafts" tab.
1054
+
1055
+ Included: nodes whose **latest** version row is authored by the caller with
1056
+ status `draft` or `rejected`, and that have no pending review request (those
1057
+ are already in `/me/work` under `waiting`). Approved/published documents are
1058
+ deliberately excluded — they are not stranded work. Rows for deleted documents
1059
+ are filtered out.
1060
+
1061
+ ```json
1062
+ {
1063
+ "drafts": [
1064
+ {
1065
+ "nest_id": "<nestId>",
1066
+ "nest_name": "GTM",
1067
+ "node_id": "nodes/gtm/cohere",
1068
+ "title": "Cohere Kill Sheet",
1069
+ "folder": "gtm",
1070
+ "status": "draft",
1071
+ "version": 3,
1072
+ "updated_at": "2026-07-23 09:12:00"
1073
+ }
1074
+ ],
1075
+ "pagination": { "total": 137, "limit": 25, "offset": 0, "has_more": true }
1076
+ }
1077
+ ```
1078
+
1079
+ `total` is counted in SQL, before the existence filter that drops orphaned
1080
+ version rows (a document deleted outside the app), so it can run one high and
1081
+ leave a page one row short. `has_more` is derived from the **requested** page
1082
+ size for that reason, not from the number of rows returned.
1083
+
1084
+ Submitting one is the ordinary `POST /nests/:nestId/nodes/:nodeId/submit-review`
1085
+ — it then leaves this list and appears in `/me/work`.
1086
+
1087
+ ---
1088
+
1089
+ ## 7. Versions
1090
+
1091
+ ### GET `/nests/:nestId/nodes/:nodeId/versions`
1092
+
1093
+ Version metadata plus each version's **change log** — the unified diff taking the
1094
+ previous version to this one, as the engine stored it. `diff` is absent for v1
1095
+ and for keyframe versions (a keyframe is a full snapshot, so there is no patch).
1096
+
1097
+ `content` is always `""` here. The list deliberately does **not** carry bodies:
1098
+ attaching them means replaying the version chain once per row and shipping N
1099
+ full copies of the document just to open the history panel. Fetch the one body
1100
+ you need from `GET .../versions/:version` below.
1101
+
1102
+ Each version may also include `resolvedBy` + `resolutionStatus`, the steward who
1103
+ approved or rejected it, joined from `review_requests` (`node_versions` stores
1104
+ the status but not the resolver's email).
1105
+
1106
+ `externalEditVerdicts` carries the verdicts on out-of-band (direct filesystem)
1107
+ edits, oldest first, read from the engine's append-only chain-event log. They sit
1108
+ beside the version list rather than inside it because a **rejection commits no
1109
+ content and therefore has no version** — minting one would renumber the chain.
1110
+ An approval does commit, so it appears in both: as the version it created, and as
1111
+ a verdict here. `note` is the rejection reason, or the approver's comment when
1112
+ one was left.
1113
+
1114
+ Gated by the same node-level read check as the document itself — `403` when the
1115
+ caller can't read the node, which for a public reader means any node without an
1116
+ approved version.
1117
+
1118
+ ```json
1119
+ {
1120
+ "currentVersion": 3,
1121
+ "approvedVersion": 2,
1122
+ "versions": [
1123
+ {
1124
+ "version": 3,
1125
+ "editedBy": "author@x.com",
1126
+ "editedAt": "...",
1127
+ "status": "pending_review",
1128
+ "changeNote": "...",
1129
+ "content": "",
1130
+ "diff": "Index: v2\n===...\n--- v2\tv2\n+++ v2\tv3\n@@ -1,4 +1,5 @@\n title: doc\n-old line\n+new line\n"
1131
+ },
1132
+ {
1133
+ "version": 2,
1134
+ "editedBy": "author@x.com",
1135
+ "editedAt": "...",
1136
+ "status": "approved",
1137
+ "changeNote": "...",
1138
+ "content": "",
1139
+ "resolvedBy": "reviewer@x.com",
1140
+ "resolutionStatus": "approved"
1141
+ }
1142
+ ],
1143
+ "externalEditVerdicts": [
1144
+ {
1145
+ "suggestion_id": "20240115T103000-a1b2c3d4",
1146
+ "status": "rejected",
1147
+ "actor": "reviewer@x.com",
1148
+ "at": "2024-01-15T10:34:00.000Z",
1149
+ "note": "not sourced",
1150
+ "source": "out-of-band-edit"
1151
+ }
1152
+ ]
1153
+ }
1154
+ ```
1155
+
1156
+ ### GET `/nests/:nestId/nodes/:nodeId/versions/:version`
1157
+
1158
+ One version's full body, reconstructed on demand from the nearest keyframe plus
1159
+ the diffs after it.
1160
+
1161
+ `content` is `null` — with HTTP 200, not an error — when that version's chain can
1162
+ no longer be replayed (e.g. history grafted by an older import). Callers render
1163
+ it as "no content" rather than failing the whole panel.
1164
+
1165
+ Same node-level read check as the list above: `403` when the caller can't read
1166
+ the node. A public reader can only reach versions of a node that has an approved
1167
+ version, so drafts of an unapproved document are never reconstructable this way.
1168
+
1169
+ ```json
1170
+ { "version": 2, "content": "# Heading\n\nbody as of v2\n" }
1171
+ ```
1172
+
1173
+ ---
1174
+
1175
+ ## 8. Comments & activity
1176
+
1177
+ Comments are a standalone collaboration primitive — **not** part of the
1178
+ version/hash chain. They thread, carry attribution, anchor to a highlighted
1179
+ span, and resolve (Word/Docs-style "Comments", separate from Track Changes).
1180
+ Any caller with **read** access to the nest may leave or resolve a comment.
1181
+
1182
+ ### GET `/nests/:nestId/nodes/:nodeId/comments?status=open`
1183
+ List comments on a node. Optional `status` = `open` | `resolved`.
1184
+ ```json
1185
+ {
1186
+ "comments": [
1187
+ {
1188
+ "id": "…",
1189
+ "nestId": "…",
1190
+ "nodeId": "nodes/spec",
1191
+ "version": 1,
1192
+ "anchor": { "start": 8, "end": 19, "text": "first draft" },
1193
+ "parentId": null,
1194
+ "author": "owner@x.com",
1195
+ "body": "This needs a citation.",
1196
+ "status": "open",
1197
+ "createdAt": "…"
1198
+ }
1199
+ ]
1200
+ }
1201
+ ```
1202
+
1203
+ ### POST `/nests/:nestId/nodes/:nodeId/comments`
1204
+ Leave a comment. Body: `{ body, version?, anchor?{start,end,text?}, parentId? }`.
1205
+ `parentId` threads a reply. Returns `201 { comment }`. Empty body → `400`.
1206
+
1207
+ **`@mentions` notify.** Every name in the comment body — `@user@host`,
1208
+ `@localpart`, or `@(Display Name)` — is resolved against the nest's people
1209
+ (`GET /nests/:nestId/mentionable`) and each person named gets a `mention` row
1210
+ in `GET /me/notifications` linking the document, plus a message on the
1211
+ configured connector and an email to their own address when SMTP is set up.
1212
+ A channel message names who was mentioned (a room is not a person); the inbox
1213
+ row and the email address the recipient directly. The author is never notified
1214
+ about their own mention, a name matching nobody
1215
+ (or matching two people ambiguously) notifies nobody, and a recipient who
1216
+ can't open the document is skipped — so a mention can't disclose a title.
1217
+ Unlike a document body, where only names a save *adds* notify, every name in a
1218
+ comment notifies: the comment itself is the new event. At most 25 people are
1219
+ notified per write (`MAX_MENTION_RECIPIENTS`) — commenting is read-tier and
1220
+ unthrottled, so a comment naming a large roster would otherwise be an
1221
+ amplifier; names are taken in order of appearance. With `PEOPLE_SUGGEST_ALL_USERS`
1222
+ on, a name that matches a registered account off the nest is an invite: a
1223
+ write+ author's mention adds them as a `read` collaborator and then notifies;
1224
+ a read-only author's mention of an outsider still reaches nobody.
1225
+
1226
+ The *stream* of them is bounded too: per nest per 5 minutes, one person may
1227
+ cause 50 mention **emails** (`MENTION_DELIVERY_BUDGET`) and 25 mention
1228
+ **channel posts** (`MENTION_CHANNEL_BUDGET`) — separate pools, because emails
1229
+ scale with how many people were named while a channel post is one message per
1230
+ comment. Separate also so the email allowance doesn't depend on something
1231
+ invisible: the connector dispatch is a no-op on a nest with no connector
1232
+ configured, and a shared pool would have spent the token before it could say
1233
+ so, quietly halving the email budget on exactly the deployments least likely to
1234
+ have a connector. Past that the comment still posts and the
1235
+ inbox rows still land — the queue is the recipient's own record and costs one
1236
+ insert — and only the external fan-out is skipped, with a server-log warning.
1237
+ The budget sits on delivery rather than on comment creation deliberately: an
1238
+ agent working through a review posts a comment per finding, which is exactly
1239
+ what `context_comment` is for, and throttling the write would break it. The same applies to the
1240
+ annotation thread endpoints (§ Hosted artifacts), which are what the UI's
1241
+ comments panel posts.
1242
+
1243
+ ### POST `/nests/:nestId/nodes/:nodeId/comments/:commentId/resolve`
1244
+ Mark a comment resolved. Records `resolvedBy` + `resolvedAt`. Returns `{ comment }`.
1245
+
1246
+ ### GET `/nests/:nestId/nodes/:nodeId/activity` · GET `/nests/:nestId/activity`
1247
+ Per-node (or nest-wide) activity log — "who did what". A read-only aggregation
1248
+ over comments + committed edits (`node_versions`) + pending external-edit
1249
+ suggestions ("proposed an edit", read from nest storage) + reviews
1250
+ (`review_requests`); newest first, `?limit=` (default 100). Each entry:
1251
+ `{ type, nodeId, actor, at, detail?, refId? }` where `type` ∈
1252
+ `comment | comment_resolved | edit | edit_proposed | review_requested | review_resolved`.
1253
+
1254
+
1255
+ ### Annotations — anchored threads the editor's Comment mode shows
1256
+
1257
+ The comments above are flat per-node threads. **Annotations** are the highlight-anchored threads the editor's Comment mode and the artifact viewer show; MCP's `context_comments` / `context_comment` / `context_resolve_comment` operate on these. Read permission to view, write to post.
1258
+
1259
+ - `GET /nests/:nestId/nodes/:nodeId/annotations` — list threads with their replies and `open` / `resolved` status.
1260
+ - `POST /nests/:nestId/nodes/:nodeId/annotations` — open a thread. Body: `{ body, anchor?: { start, end, text }, snapshotVersion? }`. No `anchor` = a whole-document (or whole-artifact) thread.
1261
+ - `POST /nests/:nestId/nodes/:nodeId/annotations/:threadId/comments` — reply. Body: `{ body }`.
1262
+ - `POST /nests/:nestId/nodes/:nodeId/annotations/:threadId/resolve` · `/reopen`.
1263
+ - `GET /nests/:nestId/comment-counts` — `{ "<nodeId>": <open thread count>, … }` for the whole nest in one call (the UI's "most commented" sort).
1264
+
1265
+ ---
1266
+
1267
+ ## 8b. Glossary definitions
1268
+
1269
+ A literal table-based ontology per nest: stewards/editors define and articulate
1270
+ terms — existing `#tags` or brand-new vocabulary — and external runtimes (quant
1271
+ analysis jobs, agents) pull them in real time. One row per `(nest, term)`,
1272
+ case-insensitive. Reads are read-tier; writes are write-tier.
1273
+
1274
+ ### GET `/nests/:nestId/definitions`
1275
+ List the whole glossary, ordered by term.
1276
+ ```json
1277
+ {
1278
+ "count": 1,
1279
+ "definitions": [{
1280
+ "id": "...",
1281
+ "nest_id": "...",
1282
+ "term": "CAC",
1283
+ "definition": "Customer acquisition cost: S&M spend / new customers, 90d.",
1284
+ "linked_tag": "#gtm",
1285
+ "defined_by": "owner@acme.com",
1286
+ "created_at": "...",
1287
+ "updated_at": "..."
1288
+ }]
1289
+ }
1290
+ ```
1291
+
1292
+ ### GET `/nests/:nestId/definitions?term=CAC`
1293
+ Real-time single lookup — one indexed, case-insensitive query. `404` if the term
1294
+ isn't defined.
1295
+ ```json
1296
+ { "definition": { "id": "...", "term": "CAC", "definition": "...", "linked_tag": "#gtm", "defined_by": "...", "created_at": "...", "updated_at": "..." } }
1297
+ ```
1298
+
1299
+ ### POST `/nests/:nestId/definitions` (write+)
1300
+ Upsert by term text (case-insensitive). New term → `201`; re-defining an existing
1301
+ term updates it in place → `200`.
1302
+ ```json
1303
+ { "term": "CAC", "definition": "...", "linked_tag": "gtm", "editing_id": "<id?>" }
1304
+ ```
1305
+ - `term` required, ≤ 200 chars. `definition` required. Non-string `term`/`definition`/`linked_tag` → `400`.
1306
+ - `linked_tag`: omit = leave unchanged on update; `null`/`""` = clear; string = set (normalized to `#lowercase`).
1307
+ - `editing_id` (optional): the id of the row being edited. Scopes the upsert to
1308
+ that row so a **rename replaces it in place** instead of overwriting whichever
1309
+ row happens to own the new term. If the new term already belongs to a
1310
+ **different** row → `409` (no data loss). Unknown `editing_id` → `404`. Omit it
1311
+ for the plain add form and real-time writers (pure upsert-by-term).
1312
+
1313
+ ### DELETE `/nests/:nestId/definitions/:id` (write+)
1314
+ ```json
1315
+ { "deleted": true }
1316
+ ```
1317
+ `404` if the id isn't in this nest.
1318
+
1319
+ ---
1320
+
1321
+ ## 8c. Edge-type registry ("turing" workflow plane)
1322
+
1323
+ The vocabulary of connections: the definitions-table move applied to relations.
1324
+ Stewards articulate once what `next` or `escalates-when` MEANS in a nest; edges
1325
+ then reference the registry. **Feature-flagged** — every route here `404`s
1326
+ unless `FEATURE_WORKFLOW_PLANE` is on (env var or the admin-settings toggle).
1327
+ Reads are read-tier; writes are write-tier. One row per `(nest, name)`,
1328
+ case-insensitive.
1329
+
1330
+ On first touch a nest is lazily seeded with five stock types (attributed to the
1331
+ nest owner): `next`, `on-success`, `on-failure` (flow types, form the DAG),
1332
+ `depends-on`, `owned-by`.
1333
+
1334
+ ### GET `/nests/:nestId/edge-types`
1335
+ List the registry, ordered by name. Seeds the defaults if empty.
1336
+ ```json
1337
+ {
1338
+ "count": 5,
1339
+ "edge_types": [{
1340
+ "id": "...",
1341
+ "nest_id": "...",
1342
+ "name": "next",
1343
+ "description": "Unconditional flow: after the source completes, run the target.",
1344
+ "direction": "directed",
1345
+ "is_flow": true,
1346
+ "condition_schema": null,
1347
+ "color": "#16a34a",
1348
+ "created_by": "owner@acme.com",
1349
+ "created_at": "...",
1350
+ "updated_at": "..."
1351
+ }]
1352
+ }
1353
+ ```
1354
+
1355
+ ### POST `/nests/:nestId/edge-types` (write+)
1356
+ Upsert by name (case-insensitive). New name → `201`; re-defining an existing
1357
+ name updates it in place → `200`.
1358
+ ```json
1359
+ {
1360
+ "name": "escalates-when",
1361
+ "description": "Conditional escalation on a metric guardrail.",
1362
+ "direction": "directed",
1363
+ "is_flow": true,
1364
+ "color": "#f59e0b",
1365
+ "condition_schema": { "params": ["term", "op", "value"], "mode_default": "structured" }
1366
+ }
1367
+ ```
1368
+ - `name` required, ≤ 100 chars, `^[a-z0-9][a-z0-9-]*$` (alphanumeric-with-dashes). `description` required.
1369
+ - `direction`: `directed` (default) | `undirected`. Anything else → `400`.
1370
+ - `is_flow`: boolean, default `false`. Flow types form the workflow DAG.
1371
+ - `color`: optional 6-digit hex (e.g. `#16a34a`). A malformed color → `400` (not silently dropped).
1372
+ - `condition_schema`: optional `{ "params": [string…], "mode_default"?: "structured"|"nl"|"open" }`. Malformed → `400`. Stored normalized (`mode_default` defaults to `structured`).
1373
+
1374
+ ### DELETE `/nests/:nestId/edge-types/:id` (write+)
1375
+ ```json
1376
+ { "deleted": true }
1377
+ ```
1378
+ `404` if the id isn't in this nest. `400` if edges still reference the type
1379
+ (delete those edges first — the DB enforces this with `ON DELETE RESTRICT`).
1380
+
1381
+ ---
1382
+
1383
+ ## 9. Query / Search (read-only consumers)
1384
+
1385
+ These power LLM context retrieval. Open-mode friendly.
1386
+
1387
+ ### GET `/nests/:nestId/search?q=<term>`
1388
+ Full-text search.
1389
+ ```json
1390
+ { "count": N, "nodes": [{ "id": "...", "title": "...", "tags": [...] }] }
1391
+ ```
1392
+
1393
+ ### GET `/search?q=<term>&limit=20`
1394
+ The same match, run across every nest the caller can see — for finding a
1395
+ document without knowing which nest holds it. Each hit is stamped with its
1396
+ nest, and per-nest stewardship filtering still applies, so results never
1397
+ include a document the caller couldn't open directly.
1398
+
1399
+ `limit` caps the returned page (default 20, max 50) while `count` reports
1400
+ everything found. `400` if `q` is missing.
1401
+ ```json
1402
+ {
1403
+ "query": "onboarding",
1404
+ "count": 2,
1405
+ "nodes": [
1406
+ {
1407
+ "id": "nodes/onboarding-runbook",
1408
+ "title": "Onboarding Runbook",
1409
+ "type": "document",
1410
+ "tags": ["#hr"],
1411
+ "snippet": "how to onboard a...",
1412
+ "nest_id": "nest_abc",
1413
+ "nest_name": "Team Knowledge"
1414
+ }
1415
+ ]
1416
+ }
1417
+ ```
1418
+
1419
+ ### POST `/nests/:nestId/context`
1420
+ One-call context retrieval for app/agent flows. Returns markdown ready to
1421
+ prepend to an LLM prompt, plus a trace + node manifest. **Requires a license**
1422
+ (it's a `POST`).
1423
+
1424
+ Body (give `prompt` OR `selector`; everything else optional):
1425
+ ```json
1426
+ {
1427
+ "prompt": "how does auth work",
1428
+ "selector": "#auth",
1429
+ "max_tokens": 4000,
1430
+ "hops": 2,
1431
+ "include_drafts": false
1432
+ }
1433
+ ```
1434
+ - `prompt` — natural language. Tokens are matched against tag names and titles, and every document is scored against them (title hits weigh most, then tags, then body words); documents under half the top score are dropped as noise. A prompt that matches nothing returns `nodes: []` — never the whole nest. `trace.compiled.fallback` is `"fulltext"` when body scoring alone produced the answer.
1435
+ - `selector` — explicit selector grammar (e.g. `#auth`). Wins over `prompt`.
1436
+ - `hops` — wikilink expansion depth. Defaults to `0` for a `prompt` (neighbours of a keyword match are not answers to the question) and `2` for a `selector`.
1437
+ - `max_tokens` — budget, floored at 50, default 4000. A node that doesn't fit is skipped and the next tried (`trace.truncated_by_budget` counts the skips); only when nothing fits does the first node go through oversized.
1438
+ - 400 `prompt or selector is required` if both missing.
1439
+ - Each node carries a `url` deep link, and its context block a matching `_source: <url>_` line, so answers can cite sources. Built from `PUBLIC_BASE_URL`, falling back to the request origin; omitted when neither resolves.
1440
+ - On the `prompt` path each node also carries `score` (0–1; title hits weigh most, then tags, then body) and `via` (`"title"` | `"tag"` | `"fulltext"` | `"hop"`). `nodes` and the `context` blocks are ordered by score, so the budget keeps the best matches. Cite `via != "hop"`. Selector-path responses omit both fields.
1441
+
1442
+ Response (200):
1443
+ ```json
1444
+ {
1445
+ "context": "# Auth Overview\n\n_tags: #auth #security_\n_source: http://localhost:3838/?nest=<nestId>&doc=nodes/auth-overview_\n\n# Auth\n\n...",
1446
+ "nodes": [{ "id": "nodes/auth-overview", "title": "Auth Overview", "url": "http://localhost:3838/?nest=<nestId>&doc=nodes/auth-overview", "tags": ["#auth"], "type": "document", "score": 0.67, "via": "tag" }],
1447
+ "trace": {
1448
+ "selector_used": "#auth",
1449
+ "from_prompt": "how does auth work",
1450
+ "compiled": { "matched_tags": ["auth"], "matched_titles": [], "unmatched_tokens": ["work"], "fallback": null },
1451
+ "included": 1,
1452
+ "permission_filtered": 0,
1453
+ "truncated_by_budget": 0,
1454
+ "approx_tokens": 24,
1455
+ "max_tokens": 4000
1456
+ }
1457
+ }
1458
+ ```
1459
+
1460
+ ```bash
1461
+ curl -X POST 'http://localhost:3838/nests/<nestId>/context' \
1462
+ -H 'Content-Type: application/json' \
1463
+ -H 'Authorization: Bearer cnst_...' \
1464
+ -d '{"prompt":"how does auth work","max_tokens":4000}'
1465
+ ```
1466
+ Permission gating matches `/export` (public readers only see approved bodies).
1467
+
1468
+ ### GET `/nests/:nestId/export?format=markdown` — LLM-pointed plain-text view
1469
+ Stable URL an agent can fetch to get **`text/markdown`** (not JSON): every
1470
+ accessible node serialized as YAML frontmatter + body, joined with `---`
1471
+ separators. A `GET`, so it works on public nests without a license/credentials.
1472
+
1473
+ **Approved-only, for every caller.** Export never emits unapproved drafts —
1474
+ not even for the owner. On a governed (stewardship-on) nest, each node's body
1475
+ is its **approved version**, and nodes with no approved version yet are
1476
+ omitted entirely. On an ungoverned nest there's no draft concept, so the
1477
+ current body is exported.
1478
+
1479
+ Query params:
1480
+ - `format=markdown` — **required** (else 400 `format=markdown is required`).
1481
+ - `selector=<grammar>` — optional, narrows the set (e.g. `#auth`).
1482
+ - `max_tokens=<n>` — optional budget; full set by default.
1483
+
1484
+ Response (`text/markdown; charset=utf-8`):
1485
+ ```markdown
1486
+ ---
1487
+ title: "Auth Overview"
1488
+ tags: ["#auth", "#security"]
1489
+ status: "published"
1490
+ id: "nodes/auth-overview"
1491
+ ---
1492
+ # Auth
1493
+
1494
+ We use device auth + email/password fallback.
1495
+
1496
+ ---
1497
+
1498
+ ---
1499
+ title: "Rate Limits"
1500
+ tags: ["#api"]
1501
+ status: "published"
1502
+ id: "nodes/rate-limits"
1503
+ ---
1504
+ # Limits
1505
+
1506
+ 100 req/min per key.
1507
+ ```
1508
+
1509
+ ```bash
1510
+ # whole nest
1511
+ curl 'http://localhost:3838/nests/<nestId>/export?format=markdown'
1512
+
1513
+ # narrow by selector
1514
+ curl 'http://localhost:3838/nests/<nestId>/export?format=markdown&selector=%23auth'
1515
+
1516
+ # cap output size
1517
+ curl 'http://localhost:3838/nests/<nestId>/export?format=markdown&max_tokens=2000'
1518
+ ```
1519
+
1520
+ Single node, same shape — add `?format=markdown` to the node read:
1521
+ ```bash
1522
+ curl 'http://localhost:3838/nests/<nestId>/nodes/nodes/auth-overview?format=markdown'
1523
+ ```
1524
+ Public readers receive the **approved** version body, never an unapproved
1525
+ working-copy draft (same gate as `/context`).
1526
+
1527
+ ### GET `/nests/:nestId/export?format=bundle` — portability bundle
1528
+
1529
+ Downloads a `.zip` of the nest's documents to move it to another ContextNest host. Each document is serialized from its approved/published state (`status: published`). Any collaborator with nest access (viewer and up) may export — same gate as the markdown export above. There is **no** separate import endpoint — unzip the bundle and bring it in with the existing folder importer (`POST /nests/import`).
1530
+
1531
+ - `?includeDrafts=1` — also export unapproved draft documents. Default: approved/published only.
1532
+
1533
+ Identities/attribution and the governance overlay (stewards, reviews, comments, definitions, edges, grants) do **not** travel; the importer owns and re-governs the fresh nest.
1534
+
1535
+ ```bash
1536
+ curl -OJ 'http://localhost:3838/nests/<nestId>/export?format=bundle'
1537
+ curl -OJ 'http://localhost:3838/nests/<nestId>/export?format=bundle&includeDrafts=1'
1538
+ ```
1539
+
1540
+ ### POST `/nests/:nestId/table-query`
1541
+ Deterministic fact lookup against a **table** node (CSV body): fetch the exact rows instead of dragging the whole table into context. Same query against the same version always returns the same rows. Read permission; drafts excluded unless `include_drafts: true`.
1542
+ ```json
1543
+ { "table": "[[Price List]]", "query": "where region = \"EU\" and price > 100 select sku, price limit 5" }
1544
+ ```
1545
+ `table` is a `[[Title]]` or a node id (`nodes/price-list`). Grammar: `where <col> <op> <value> [and …] [select <cols>] [limit N]`. Response:
1546
+ ```json
1547
+ { "table": "nodes/price-list", "version": 3, "columns": ["sku", "price"], "rows": [["A-1", "120"]], "matched": 7, "returned": 5, "total": 1000 }
1548
+ ```
1549
+ Same operation over MCP: `context_table_query`.
1550
+
1551
+ ### POST `/nests/:nestId/query`
1552
+ Graph query (Cypher-ish).
1553
+
1554
+ ### GET `/nests/:nestId/overview`
1555
+ Nest-level summary stats.
1556
+
1557
+ ### GET `/nests/:nestId/context`
1558
+ Returns CONTEXT.md content.
1559
+
1560
+ ### GET `/nests/:nestId/folders`
1561
+ The nest's folder tree on its own, for callers that want the *shape* of the vault
1562
+ (a navigable tree, a folder picker) and none of its content. Per-folder counts come
1563
+ from the document index and the folders themselves from the vault's directories —
1564
+ no document is opened either way, so this stays cheap on a vault where listing the
1565
+ documents would not be. A folder holding no document is listed with `count: 0`.
1566
+
1567
+ Query: `?folder=<path>` (that subtree only; defaults to the whole nest),
1568
+ `?recursive=0` (direct children of `folder` only).
1569
+
1570
+ ```json
1571
+ { "folders": [{ "path": "specs", "count": 4, "total": 11 }] }
1572
+ ```
1573
+
1574
+ `count` = documents filed directly in that folder; `total` = the whole subtree
1575
+ beneath it. A level that holds only subfolders still gets a row, so it stays
1576
+ expandable.
1577
+
1578
+ ### POST `/nests/:nestId/folders`
1579
+ ```json
1580
+ { "folder": "gtm/deals" }
1581
+ ```
1582
+ Creates a folder with nothing in it — the directory is created in the vault
1583
+ carrying only its generated `INDEX.md`, so no document is added to the nest.
1584
+ Each segment is slugified (`GTM/Deals` → `gtm/deals`) and the depth cap applies.
1585
+ Creating a folder that already exists is a no-op. Write tier.
1586
+
1587
+ ```json
1588
+ { "folder": "gtm/deals" }
1589
+ ```
1590
+ Returns 201 with the path actually created.
1591
+
1592
+ ### GET `/nests/:nestId/census`
1593
+ One call for everything a nest overview needs to describe the *whole* nest,
1594
+ whatever folder is open — counts by tag, type and status, the folder tree, the
1595
+ author roster, and the recently-edited strip. Gated by the same `filterAccessible`
1596
+ pass as the listing, so the numbers describe what this caller can actually reach.
1597
+
1598
+ ```json
1599
+ {
1600
+ "census": {
1601
+ "total": 42,
1602
+ "folders": [{ "path": "specs", "count": 4, "total": 11 }],
1603
+ "tags": { "#api": 7 },
1604
+ "types": { "document": 40, "agent": 2 },
1605
+ "statuses": { "approved": 30, "draft": 12 },
1606
+ "authors": ["a@x.com"],
1607
+ "editedLast7d": 5,
1608
+ "recent": [{ "id": "nodes/foo", "title": "Foo", "type": "document", "updated_at": "..." }]
1609
+ }
1610
+ }
1611
+ ```
1612
+
1613
+ ### POST `/nests/:nestId/reindex` (write+)
1614
+ Force-rebuild the nest's document index from the vault, and return how many
1615
+ documents were indexed: `{ "indexed": 42 }`.
1616
+
1617
+ The index heals itself — anything that could leave it out of step with disk (an
1618
+ out-of-band edit, an import, a write that could not be mirrored) marks the nest
1619
+ stale, and the next read rebuilds before answering. This is the manual lever for
1620
+ when an operator wants that to happen *now*. A rebuild crawls the whole vault, so
1621
+ it is write-tier rather than something a read-only caller can trigger at will;
1622
+ below that the answer is `403`.
1623
+
1624
+ ### GET `/nests/:nestId/graph` (read+)
1625
+ Ontology graph — a read-only assembly over data the nest already holds, for the
1626
+ graph view (links / stewardship overlay / coverage gaps). Gated to what the
1627
+ caller can read (same `filterAccessible` pass as the other query routes).
1628
+
1629
+ Response `NestGraph`:
1630
+ ```json
1631
+ {
1632
+ "nodes": [{ "id": "nodes/alpha", "title": "Alpha", "type": "document", "tags": ["market-x"] }],
1633
+ "links": [{ "source": "nodes/alpha", "target": "nodes/beta" }],
1634
+ "stewards":[{ "id": "jane@acme.com", "label": "jane@acme.com", "role": "reviewer", "scope": "tag", "target": "market-x" }],
1635
+ "stewardEdges":[{ "steward": "jane@acme.com", "target": "tag:market-x", "scope": "tag" }],
1636
+ "tags": [{ "name": "market-x", "count": 2 }],
1637
+ "signals": {
1638
+ "orphans": [{ "id": "nodes/loose", "title": "Loose" }],
1639
+ "unstewardedTags": [{ "name": "market-y", "count": 1 }],
1640
+ "bottlenecks": [{ "steward": "jane@acme.com", "label": "jane@acme.com", "share": 67 }]
1641
+ },
1642
+ "truncated": false,
1643
+ "totalNodes": 3
1644
+ }
1645
+ ```
1646
+
1647
+ Notes:
1648
+ - **Identity masking** — steward `id`/`label` are the real email only for **write+**
1649
+ callers (same rule as the collaborators roster, so a public/read tier can't
1650
+ enumerate member emails). Sub-write callers get an opaque `"s1"` id and a
1651
+ `"Steward 1"` label; `bottlenecks[].label` follows suit.
1652
+ - **Roster gating** — `stewards`/`stewardEdges` are empty for public/anonymous
1653
+ readers and when the nest has stewardship disabled.
1654
+ - **Draw cap** — `nodes`/`links` are capped at 150 (highest-degree kept) with
1655
+ `truncated: true` and `totalNodes` set; `signals` are still computed over the
1656
+ full set so the gap numbers stay accurate.
1657
+
1658
+ ### GET `/nests/:nestId/runnables` (read+)
1659
+ Orchestrator surface: every **runnable** node (`type` `agent` or `skill`) with its
1660
+ `schedule` and body in one call, so an external scheduler can connect, discover
1661
+ what to run and when, and pull the prompt/skill content. Reuses the same
1662
+ per-caller gating as the node list (`listNodesForCaller`), so stewardship and the
1663
+ public-reader approved-only rule apply — a read-tier orchestrator key only ever
1664
+ sees **approved** agent/skill definitions; unapproved drafts are omitted.
1665
+
1666
+ ```json
1667
+ {
1668
+ "count": 1,
1669
+ "runnables": [{
1670
+ "id": "nodes/nightly-digest-agent",
1671
+ "title": "Nightly Digest Agent",
1672
+ "type": "agent",
1673
+ "schedule": "0 6 * * *",
1674
+ "status": "published",
1675
+ "tags": ["#ops"],
1676
+ "updated_at": "...",
1677
+ "content": "# Agent\n\n..."
1678
+ }]
1679
+ }
1680
+ ```
1681
+ - `schedule` is `null` when the node carries no schedule.
1682
+ - Filtering to runnable types happens before the per-node governance/version
1683
+ enrichment, so cost scales with the number of runnables, not total nest size.
1684
+
1685
+ ### POST `/nests/:nestId/publish`
1686
+ Bulk-create documents (and/or update `CONTEXT.md`) in one call. Each document
1687
+ routes through the same create path as `POST /nests/:nestId/nodes` — same slug
1688
+ derivation, same governance rows, same content-type policy gate (a disabled
1689
+ `artifact`/`table` type fails the whole batch up front, before any write).
1690
+
1691
+ Body: `{ documents?, context_md? }` — at least one required.
1692
+ `documents[]` rows are `{ title, content, type?, tags?, scope? }`; rows missing
1693
+ `title` or `content` are skipped silently (a shape problem, not a policy one).
1694
+
1695
+ A document whose title slugs to an id that already exists is **skipped and
1696
+ reported** — never overwritten, so republishing a folder cannot destroy an
1697
+ existing document's version history.
1698
+
1699
+ A document the create path rejects for its own reasons — a title that is too
1700
+ long or carries no sluggable characters, a `type` the engine does not know — is
1701
+ **reported in `failed` and the batch continues**. One bad row cannot abandon the
1702
+ rest half-written.
1703
+
1704
+ ```json
1705
+ {
1706
+ "published": 2,
1707
+ "context_md_updated": true,
1708
+ "node_ids": ["nodes/alpha", "nodes/beta"],
1709
+ "skipped": ["Existing Doc Title"],
1710
+ "failed": [{ "title": "Bad Row", "error": "..." }]
1711
+ }
1712
+ ```
1713
+
1714
+ Returns `201` whenever the batch ran, even if every row landed in `skipped` or
1715
+ `failed` — read the arrays, not the status, to know what happened.
1716
+
1717
+ `context_md` is written directly rather than through an operation, because none
1718
+ exists for it. It is therefore outside the vault write lock and has no `503` to
1719
+ return — a `context_md`-only request cannot report contention.
1720
+
1721
+ **Partial writes and retries.** The batch is not a transaction: documents are
1722
+ created one at a time and the ones written before a failure stay written. The
1723
+ one error that stops the batch early is the vault write lock being held past
1724
+ its acquire timeout, which answers `503` + `Retry-After`. Retrying is safe and
1725
+ finishes the job — every document from the first attempt now exists, so it
1726
+ comes back under `skipped` while the remaining ones are created. Expect a
1727
+ lower `published` count on the retry; that is the batch converging, not a
1728
+ failure.
1729
+
1730
+ ---
1731
+
1732
+ ## 8c. Publish links — public hosting of a single document
1733
+
1734
+ Expose ONE document as a standalone page at a stable, unguessable
1735
+ `/p/<slug>` URL. Distinct from nest visibility (whole-nest read) and from
1736
+ grants (authenticated per-user sharing). Managing publish links is owner/admin
1737
+ only. Access modes: `public` (no auth), `code` (shared passcode), `email`
1738
+ (viewer enters an email → one-time magic link → access logged), `invite`
1739
+ (same, but only allow-listed emails get a link). Every served view is logged;
1740
+ links are revocable and can carry `expires_at`.
1741
+
1742
+ ### POST `/nests/:nestId/publications` (owner/admin)
1743
+ Body: `{ node_id, access_mode?: "public"|"email"|"code"|"invite",
1744
+ comments_enabled?: bool, watermark?: bool, no_download?: bool,
1745
+ code?: string (code mode), allowlist?: string[] (invite mode),
1746
+ expires_at?: ISO }`. → `201 { publish, link }`.
1747
+
1748
+ `watermark` stamps the viewer's email across the page — email/invite gates only,
1749
+ since the other two identify nobody. `no_download` blocks copy, right-click,
1750
+ drag and printing; both are deterrents, not protection (screenshots and
1751
+ developer tools still work). `400` when `node_id` names no document in the nest,
1752
+ or when `email`/`invite` is requested on a server with no mail transport
1753
+ (`SMTP_URL` + `NOTIFY_EMAIL_FROM`) — their one-time link could never be sent.
1754
+
1755
+ ### GET `/nests/:nestId/publications` (owner/admin)
1756
+ List this nest's publish links with their `link` URLs.
1757
+
1758
+ ### GET `/nests/:nestId/publications/:id/views` (owner/admin)
1759
+ The access log — `{ count, views: [{ email, created_at }] }`.
1760
+
1761
+ ### DELETE `/nests/:nestId/publications/:id` (owner/admin)
1762
+ Revoke — the link 404s immediately.
1763
+
1764
+ ### Public render surface (no auth chain)
1765
+ - `GET /p/:slug` — the document, a gate form (email/code/invite), or — when
1766
+ `comments_enabled` — a reader shell framing the document beside the comment
1767
+ control. Document bodies are served under the strict artifact sandbox
1768
+ (opaque origin); markdown nodes are rendered server-side.
1769
+ - `GET /p/:slug/view` — the document itself, always sandboxed. The shell frames
1770
+ it; gated independently, so being the frame source is not authorization.
1771
+ - `POST /p/:slug/gate` — submit a code (unlock) or an email (send magic link).
1772
+ Rate limited per link per IP.
1773
+ - `GET /p/:slug/verify?token=` — consume a magic link → set session cookie → open.
1774
+ Invite mode re-checks the allow-list here, not only when the link was sent.
1775
+ - `POST /p/:slug/comments` — JSON `{ body, email? }`, from the shell's modal.
1776
+ Needs the gate cleared and an identifying email; lands as an unanchored
1777
+ annotation thread for stewards, never on the published page.
1778
+
1779
+ Only the approved version is ever served — a document with no approved snapshot
1780
+ 404s rather than exposing a draft. A revoked, expired or unknown slug answers
1781
+ every route above with the same HTML notice, never a hint about which it was.
1782
+
1783
+ ---
1784
+
1785
+ ## 9b. Workflow plane ("turing")
1786
+
1787
+ **Feature-flagged — every route below `404`s unless `FEATURE_WORKFLOW_PLANE=true`** (Settings → Workflow plane). Typed edges, an edge-type registry, and governed runs. Full contract in [`WORKFLOW_PLANE.md`](./WORKFLOW_PLANE.md). Reads are read-tier, mutations write-tier via the standard nest gate.
1788
+
1789
+ ### Edge types — the vocabulary of relations
1790
+
1791
+ - `GET /nests/:nestId/edge-types` (read) — list the registry. Seeds the five stock flow types (`next`, `on-success`, `on-failure`, `depends-on`, `owned-by`) on first touch.
1792
+ - `POST /nests/:nestId/edge-types` (write) — upsert by name (case-insensitive). Body: `{ name, description, direction?, is_flow?, condition_schema?, color? }`. `name` is a slug (`^[a-z0-9][a-z0-9-]*$`); `is_flow` types are DAG-validated; `condition_schema` is `{ params: string[], mode_default? }`. `201` on create, `200` on update.
1793
+ - `DELETE /nests/:nestId/edge-types/:id` (write) — refused (`400`) while any edge references it.
1794
+
1795
+ ### Edges — typed, condition-carrying links
1796
+
1797
+ - `GET /nests/:nestId/edges?type=<name|id>&node=<id>` (read) — list, optionally filtered by edge type and/or an endpoint node.
1798
+ - `POST /nests/:nestId/edges` (write) — Body: `{ from_node, to_node, type, condition_mode?, condition?, metadata? }`. Both endpoints must be real nodes in the nest; `type` must exist in the registry. `condition_mode` ∈ `structured | nl | open` (`structured` predicates whose `term` must resolve against the glossary). Creating a flow edge that would close a cycle → `409` naming the path.
1799
+ - `DELETE /nests/:nestId/edges/:id` (write).
1800
+
1801
+ ### Runs — the operational trace (server never executes)
1802
+
1803
+ - `POST /nests/:nestId/run/:agentNode` (write) — trigger a run. Body: `{ inputs?, parent_run_id? }`. Returns `201` with the executable **bundle**: `{ run_id, parent_run_id, depth, agent{content,…}, edges[<reachable flow subgraph>], definitions[], trace_hint }`. `triggered_by` is derived from auth, never client-supplied. Only `agent`/`skill` nodes are runnable (else `400`). `parent_run_id` spawns a **sub-agent run** nested under a still-running parent (`FEATURE_SUBAGENT_RUNS`, off by default — any `parent_run_id` is `400` while off); it's bounded by `SUBAGENT_MAX_DEPTH` (depth → `403`) and `SUBAGENT_MAX_CHILDREN` (fan-out → `403`, enforced atomically with the insert), rejects re-entrancy of an agent already in the ancestor chain (`409`), and refuses a closed (`409`) or unknown/cross-nest (`404`) parent.
1804
+ - `POST /runs/:id/steps` (write) — append one step. Body: `{ action: read|write|branch|notify|external, node_id?, edge_id?, detail? }`. `seq` is server-assigned (monotonic, atomic); any client `seq` is ignored. `409` if the run is already terminal, or once the run hits `RUN_MAX_STEPS`.
1805
+ - `PATCH /runs/:id` (write) — close/update. Body: `{ status: running|succeeded|failed|cancelled, trace? }`. Compare-and-swap on `status='running'`: a second close → `409` ("terminal is final").
1806
+ - `GET /runs/:id` (read) — full trace `{ run, steps[] }`. Freeform payloads (`run.inputs`, `run.trace`, `steps[].detail`) are redacted to `null` below write tier.
1807
+ - `GET /nests/:nestId/runs?agent=&status=&limit=` (read) — run history, newest first (same read-tier redaction).
1808
+
1809
+ > The `/runs/:id*` family mounts outside the per-nest gate, so it re-checks nest-scope keys, suspension and license itself before any write — the same guarantees the `/nests/:nestId/*` gate provides.
1810
+
1811
+ ### Schedules — run an agent on a timer
1812
+
1813
+ - `GET /nests/:nestId/schedules` (read) — list.
1814
+ - `POST /nests/:nestId/schedules` (write) — Body: `{ agent_node, every_minutes, enabled?, max_runs? }`. `max_runs` stops the schedule after that many runs.
1815
+ - `PATCH /nests/:nestId/schedules/:id` (write) — `{ every_minutes?, enabled? }`.
1816
+ - `DELETE /nests/:nestId/schedules/:id` (write).
1817
+
1818
+ Archived nests are skipped by the scheduler tick; restoring the nest resumes them.
1819
+
1820
+ ### Inbound hooks — let the outside world trigger a run
1821
+
1822
+ - `GET /nests/:nestId/hooks?agent=` (read) — list, tokens masked.
1823
+ - `POST /nests/:nestId/hooks` (write) — Body: `{ agent_node, preset }` with `preset` = `slack` | `teams` | `webhook`. Returns the secret URL once.
1824
+ - `DELETE /nests/:nestId/hooks/:id` (write) — accepts the full token or the masked id.
1825
+ - `POST /hooks/:token` (public — the token is the auth) — fire it. The Slack and Teams presets verify the request signature against `SLACK_SIGNING_SECRET` / `TEAMS_WEBHOOK_SECRET` in the nest's env store; the message text becomes the run's `inputs`. `404` while the nest is archived.
1826
+
1827
+ ### Env — per-nest secrets for tools and the runner
1828
+
1829
+ - `GET /nests/:nestId/env` (write+) — names and masked tails only; values are never read back.
1830
+ - `PUT /nests/:nestId/env` (write+) — `{ key, value }` creates or replaces.
1831
+ - `DELETE /nests/:nestId/env/:key` (write+).
1832
+
1833
+ Values ride to the runner inside the run bundle. `ANTHROPIC_API_KEY` here overrides the server-wide default from Settings.
1834
+
1835
+ ### Connectors — governance events out to a channel
1836
+
1837
+ Not behind the workflow-plane flag: connectors fire on review events too.
1838
+
1839
+ - `GET /nests/:nestId/connectors` (read).
1840
+ - `POST /nests/:nestId/connectors` (write) — Body: `{ channel, url, events }`. `channel` = `slack` | `teams` | `webhook`; `url` is a literal `https://…` or `env:KEY` to pull it from the env store; `events` is a non-empty array of `review_requested`, `review_approved`, `review_rejected`, `run_failed`, `mention`, or `["*"]`.
1841
+ - `PATCH /nests/:nestId/connectors/:id` (write) — `{ enabled?, events?, url? }`.
1842
+ - `DELETE /nests/:nestId/connectors/:id` (write).
1843
+
1844
+ ---
1845
+
1846
+ ## 9. MCP
1847
+
1848
+ ### `/nests/:nestId/mcp/*`
1849
+ Model Context Protocol endpoints — JSON-RPC over HTTP. Not exercised manually; LLM clients connect here.
1850
+
1851
+ **Transport is read-tier.** MCP Streamable HTTP is always POST (even `initialize` / `tools/list`), so the per-nest gate admits any credential with read access; every mutating tool re-checks its own permission inside the tool handler (create/edit via stewardship, approve/reject via reviewer rights, stewards/sharing/sync via their own admin checks).
1852
+
1853
+ **Denials are JSON-RPC.** Gate rejections on the MCP transport path return a JSON-RPC error envelope — `{ "jsonrpc": "2.0", "id": <echoed>, "error": { "code": -32000, "message": "<what failed — actionable reason>" } }` — instead of plain `{error}` JSON, so external clients (PromptOwl, Claude Desktop, Cursor) surface *why* a connection failed: key scoped to a different nest, server suspended, license required, or nest not found / no access.
1854
+
1855
+ **Titles are unique on this surface.** `context_get`, `context_update`, `context_versions`, `context_delete`, and `context_move` each resolve a document by title or by id (an id wins when both are sent, and an ambiguous title refuses rather than guessing). Title addressing is what makes uniqueness matter, so `context_create` refuses a title already used anywhere in the nest — including in another folder — and answers with the existing node's id plus what to do instead (`context_update` to change it, `context_move` to refile it). A duplicate would otherwise be unreachable by every tool here. REST `POST /nodes` keeps the looser rule (same title in a different folder is fine); both surfaces refuse a create whose id is already taken.
1856
+
1857
+ **`context_search` is the REST search.** The tool runs the same index-backed match as `GET /nests/:id/search` — terms against the cached discovery set (title, body, type, tags), title hits first — and gates the *hits* with the same `filterAccessible` rule (public readers: approved-only; governed nests: steward coverage or a share grant), so the two surfaces return the same documents in the same order. It takes an optional `limit` (default 20, max 200) and answers `{ results, count }`: `count` is everything found, `results` the first `limit` hits, each with its LLM-visible body (approved version under stewardship; a hit with no approved version yet is listed but carries no body). It used to resolve every document's gated body *before* matching — one permission lookup plus one version reconstruction per node — which is what timed out `ctx search --vault <remote>` on large governed nests.
1858
+
1859
+ **Deletion requests** mirror the REST + UI flow, so an agent isn't left at a dead end when `context_delete` refuses:
1860
+ - `context_request_deletion` — flag a document (by title/id), a `folder`, or the whole `nest` with a required `reason`. Read-tier: anyone who can see a thing may ask for its deletion. Nothing is removed; a refused `context_delete` now names this tool.
1861
+ - `context_deletion_queue` — pending (or `declined`) requests with their reasons and request ids.
1862
+ - `context_resolve_deletion` — `delete` or `decline` (note required) by request id. **Owner/admin only** — the transport is read-tier, so this per-tool check is the only thing between a viewer and a deleted nest.
1863
+
1864
+ **Comments read AND write.** `context_comments` reads a document's threads (anchored quotes, replies, open/resolved) — the same annotation store the web UI's comments panel shows. Two write tools close the loop so an agent handed feedback can answer it rather than only read it:
1865
+
1866
+ - `context_comment` — leave a comment. Omit `thread` to open a new whole-document thread; pass a `thread` id from `context_comments` to reply into it. One tool for both, because they differ only by that argument.
1867
+ - `context_resolve_comment` — resolve a thread, or `reopen: true` to put it back. The caller is recorded as the resolver, exactly as in the UI.
1868
+
1869
+ Both are **read-tier**, matching REST (`isAnnotationAction` in `app.ts`): a reviewer who can see the document may comment on it. The transport is read-tier too, so each tool re-checks `canReadNode` itself — that check is the real gate. Deliberately *not* gated on an approved version: a reviewer comments on drafts, and writing a comment discloses nothing (the caller supplies the text), whereas *reading* threads stays gated because anchors quote draft body text. Both run the same side effects the REST routes do — the `@mention` fan-out (so tagging a human notifies them) and the derived `— Annotations` re-projection for artifacts, so an agent's comment doesn't leave that node stale. A `thread` that belongs to another document is refused by name rather than silently opening a new thread.
1870
+
1871
+ `context_comments` is also available on the **server-level** endpoint (nest-parameterised, read-only). Comment visibility matches UI node visibility: collaborators/stewards who can read the node may read its threads even before approval; public readers remain blocked until the document is approved. Comment *writes* stay per-nest with the other write tools.
1872
+
1873
+ **`context_my_drafts`** mirrors `GET /me/drafts`, nest-scoped: the caller's own unsubmitted (or rejected) documents in this nest, with their path and version. Capped at the 100 most recent; the reply says so explicitly when there are more, so an agent can't read a capped page as the whole set. Self-scoped by construction — it only ever returns rows the caller authored, in a nest they already hold read on, so it needs no permission check of its own. It exists because an agent otherwise cannot see its own unsubmitted work: a draft has no review request, so `context_review_queue` never lists it, and `context_query` skips it (only approved versions are AI-readable). The response names `context_submit_review` as the next step.
1874
+
1875
+ Server-admin-only tools (gated by `isLicenseAdminUserId` in non-open mode, same as their REST counterparts):
1876
+ - `context_unsynced_list` — mirrors `GET /nests/unsynced`.
1877
+ - `context_sync_folder` — mirrors `POST /nests/unsynced/sync`. Args: `name` (folder path relative to `DATA_ROOT`).
1878
+
1879
+ Both reuse the same `listUnsyncedFolders` / `syncUnsyncedFolder` service functions the REST routes call, so a single fix lands in both surfaces.
1880
+
1881
+ ### Workflow-plane tools (only advertised when `FEATURE_WORKFLOW_PLANE=true`)
1882
+
1883
+ Let an MCP agent **build and execute** governed workflows without leaving the tool surface. Each shares the exact service/validator code its REST counterpart uses (run-service, edge-routes, edge-type-routes), so the two surfaces can't drift. Write tools require write tier on the nest; run-detail tools additionally scope the `run_id` to the MCP nest.
1884
+
1885
+ Build:
1886
+ - `workflow_edge_types_list` / `workflow_edge_type_upsert` / `workflow_edge_type_delete` — mirror the `/edge-types` routes.
1887
+ - `workflow_edges_list` / `workflow_edge_create` / `workflow_edge_delete` — mirror the `/edges` routes (DAG cycle guard + condition validation included).
1888
+
1889
+ Execute (the runner contract — the agent IS the runner):
1890
+ - `workflow_run` — trigger; returns the executable bundle (agent body + reachable subgraph + definitions + `run_id`).
1891
+ - `workflow_run_step` — append a step (server-assigned `seq`).
1892
+ - `workflow_run_close` — close with a terminal status (`409`-equivalent conflict on a second close).
1893
+ - `workflow_run_get` / `workflow_runs_list` — read traces/history (freeform payloads redacted below write tier).
1894
+
1895
+ ### PromptOwl native connection (service-ticket auth)
1896
+
1897
+ The official PromptOwl deployment connects on its users' behalf without storing
1898
+ a `cnst_` key: it mints a short-lived HS256 **service ticket** signed with the
1899
+ shared secret configured for it as an official community site (`/admin/community-sites`,
1900
+ or the legacy `OFFICIAL_COMMUNITY_SSO_SECRET` env var — see `CONFIGURATION.md`). The ticket
1901
+ carries `scope: "mcp"`, an `aud` bound to this server's `PUBLIC_BASE_URL`, a
1902
+ ~5-minute `exp`, and **no `jti`**. It resolves to an already-existing account by
1903
+ email — native access never auto-provisions.
1904
+
1905
+ Accepted as a `Bearer` on exactly these paths (everything else still requires a
1906
+ real credential):
1907
+ - `GET /index` and the server-level `ALL /mcp` — cross-nest index + read tools (including `context_comments`).
1908
+ - `POST /nests/:id/context` and `GET /nests/:id/nodes` — retrieval + doc listing.
1909
+ - `ALL /nests/:id/mcp` — the per-nest MCP transport, so a block granted read &
1910
+ write on one nest reaches that nest's write tools. Per-nest permission,
1911
+ suspend, and license gates all apply exactly as for a `cnst_` key.
1912
+
1913
+ Two **server-level bootstrap writes** on `ALL /mcp` let a single-connection
1914
+ client create content (everything else on server-level `/mcp` is read-only):
1915
+ - `nest_create` — new nest owned by the caller (refused for a nest-scoped key).
1916
+ - `context_create` — new document in a nest the caller can write to
1917
+ (`canCreateInNest`). Both honor the suspend kill-switch and license gate.
1918
+
1919
+ **Ticket kinds don't cross over.** An `mcp`-scoped ticket is refused at
1920
+ `GET /auth/sso` (it can't become a login session), and the MCP middleware
1921
+ refuses any ticket carrying a `jti` (the mark of a one-time SSO login ticket) —
1922
+ enforced on both sides, not by trusting the issuer.
1923
+
1924
+ ---
1925
+
1926
+ ## 10. Admin / operator
1927
+
1928
+ ### GET `/health` (no auth)
1929
+ `{ "status": "ok" | "suspended", "version": "<server version>", … }`. Needs no key (like `/llms.txt` and public `/p/:slug` pages) — use it for liveness probes. `suspended` means the license was revoked and writes are refused until a valid key is installed.
1930
+
1931
+ ### Superadmins — GET/POST `/admin/super-admins`, DELETE `/admin/super-admins/:email` (server-admin only)
1932
+ Grant and revoke **superadmins** (server-wide admins) from the API/UI instead of only via `access.yaml`. The effective superadmin set is the union of three sources, surfaced per row as `source`:
1933
+
1934
+ - `license` — the PromptOwl account that owns the installed license. Derived live; never revocable here.
1935
+ - `config` — `access.yaml` `super_admins`. The authoritative bootstrap: **never revocable via the API** (lockout safety) — edit the file and restart instead.
1936
+ - `granted` — rows in the `super_admins` DB table, managed by these endpoints.
1937
+
1938
+ All three routes use the same server-admin guard as `GET /admin/trace` (license admin, or any effective superadmin; open mode: always allowed). Emails are case-insensitive and stored lowercased. A superadmin holds `admin` on every nest, sees `/admin/trace` + `/admin/settings`, and can manage teammates (`/auth/teammates`, invite, reset-password, remove).
1939
+
1940
+ **GET `/admin/super-admins`** → `200`:
1941
+ ```json
1942
+ {
1943
+ "super_admins": [
1944
+ { "email": "owner@acme.com", "source": "license", "granted_by": null, "granted_at": null, "self": true },
1945
+ { "email": "ops@acme.com", "source": "config", "granted_by": null, "granted_at": null, "self": false },
1946
+ { "email": "jane@acme.com", "source": "granted", "granted_by": "owner@acme.com", "granted_at": "2026-07-17 14:03:22", "self": false }
1947
+ ]
1948
+ }
1949
+ ```
1950
+ An email present in several sources shows its least-revocable origin (`license` > `config` > `granted`). `self` marks the caller's own row.
1951
+
1952
+ **POST `/admin/super-admins`** with `{ "email": "jane@acme.com" }` → `201 { email, source: "granted", granted_by }`.
1953
+ - `400` — missing/malformed email, or no user account with that email exists on this server (invite them as a teammate first).
1954
+ - `409` — already an effective superadmin (any source).
1955
+
1956
+ **DELETE `/admin/super-admins/:email`** → `200 { ok: true, email }`. Guards:
1957
+ - `400` — target is the license admin, or an `access.yaml` entry (file is authoritative; remove from the file + restart).
1958
+ - `400` — **last-admin lockout guard**: you can't revoke *yourself* when you're the last effective superadmin (no license admin, no `access.yaml` entries, no other grants) — grant someone else first.
1959
+ - `404` — email has no granted row.
1960
+
1961
+ Grants persist in the database and are loaded into the in-memory resolution cache at startup and after every grant/revoke, so they survive restarts. Surfaced in the UI on **Teammates → Superadmins** with per-source badges, add-by-email, and confirm-to-revoke.
1962
+
1963
+ ### Community sites — GET/POST `/admin/community-sites`, PATCH/DELETE `/admin/community-sites/:id` (server-admin only)
1964
+ Manage the **official community sites** trusted for one-click SSO auto-login (`GET /auth/sso` — see above). Each row is a `{ name, url, secret, is_active }` connection; `url` is a display/uniqueness field, not used for audience binding. `GET /auth/sso` tries an incoming ticket's signature against every *active* site's secret, and validates the ticket's `aud` claim against this deployment's own `PUBLIC_BASE_URL` (the same value for every candidate). Replaces the single `OFFICIAL_COMMUNITY_SSO_SECRET`/`PUBLIC_BASE_URL` env-var pair with a DB-backed list for the *secret* side — the env-var pair still works as an implicit extra "legacy" entry, so existing single-site deployments are unaffected. Surfaced in the UI at **Settings → Community sites**.
1965
+
1966
+ Same server-admin guard as `/admin/super-admins` (license admin, any effective superadmin; open mode: always allowed).
1967
+
1968
+ **GET `/admin/community-sites`** → `200 { community_sites: [{ id, name, url, secret_set, is_active, created_at, updated_at }] }`. `secret` is **write-only** — never returned, only whether one is set.
1969
+
1970
+ **POST `/admin/community-sites`** with `{ name, url, secret, is_active? }` → `201` with the row shape above (`is_active` defaults to `true`).
1971
+ - `400` — missing name, invalid/non-http(s) url, or a secret under 16 characters.
1972
+ - `409` — a site with this url already exists.
1973
+
1974
+ **PATCH `/admin/community-sites/:id`** — any subset of `{ name, url, secret, is_active }`; omit `secret` to keep the current one. Same validation and `409` as POST. `404` if the id doesn't exist.
1975
+
1976
+ **DELETE `/admin/community-sites/:id`** → `200 { ok: true, id }`. `404` if the id doesn't exist.
1977
+
1978
+ Active sites are loaded into an in-memory cache at startup and refreshed after every write, so `GET /auth/sso` never blocks on a DB round trip per request.
1979
+
1980
+ ### GET `/admin/trace` (server-admin only)
1981
+ Operator activity trail — one newest-first row per API request and per MCP tool call, so the server admin has a single place to audit what every client and agent did. Server-admin only: the license admin, or a `super_admin` in `access.yaml` (open mode: always allowed). Everyone else gets `403`. Cross-tenant data; not scoped to a nest.
1982
+
1983
+ Rows are best-effort fire-and-forget writes to the `api_events` table (a broken trace table never fails a real request) on a rolling retention window of **`TRACE_RETENTION_DAYS`** days (default **14**; `0` = keep forever — see `CONFIGURATION.md`; also editable from Settings → Advanced). API rows come from a global request-logging middleware; MCP rows are logged inside `handleToolCall`, so both per-nest and server-level tool calls — including ones that throw — are captured.
1984
+
1985
+ Query params (all optional):
1986
+ - `kind` — `api` or `mcp`. Omit for both.
1987
+ - `user` — substring match on caller email, or exact user id.
1988
+ - `nest` — filter to a single nest id.
1989
+ - `limit` — page size, clamped to `1..1000` (default `25`). Non-numeric values fall back to the default rather than erroring.
1990
+ - `offset` — rows to skip for pagination (default `0`). The UI pages in blocks of 25 (`offset = page * 25`).
1991
+
1992
+ `total` is the full filtered row count (independent of `limit`/`offset`), so the client can render a pager. `count` is the number of rows on the current page.
1993
+
1994
+ Response (200):
1995
+ ```json
1996
+ {
1997
+ "count": 2,
1998
+ "total": 137,
1999
+ "limit": 25,
2000
+ "offset": 0,
2001
+ "events": [
2002
+ {
2003
+ "id": 412,
2004
+ "ts": "2026-07-03T13:58:44.812Z",
2005
+ "kind": "mcp",
2006
+ "method": null,
2007
+ "path": null,
2008
+ "tool": "context_overview",
2009
+ "nest_id": "nest_abc",
2010
+ "user_id": "usr_123",
2011
+ "caller": "alice@acme.com",
2012
+ "status": 200,
2013
+ "duration_ms": 14
2014
+ },
2015
+ {
2016
+ "id": 411,
2017
+ "ts": "2026-07-03T13:58:44.790Z",
2018
+ "kind": "api",
2019
+ "method": "POST",
2020
+ "path": "/nests/nest_abc/nodes",
2021
+ "tool": null,
2022
+ "nest_id": "nest_abc",
2023
+ "user_id": "usr_123",
2024
+ "caller": "alice@acme.com",
2025
+ "status": 201,
2026
+ "duration_ms": 23
2027
+ }
2028
+ ]
2029
+ }
2030
+ ```
2031
+ - `kind` — `api` (HTTP request) or `mcp` (tool call).
2032
+ - `method` / `path` — set on `api` rows; `null` on `mcp` rows.
2033
+ - `tool` — set on `mcp` rows; `null` on `api` rows.
2034
+ - `caller` — resolved caller email (from the row's stored email or a join on `user_id`).
2035
+ - `status` — HTTP status for `api` rows; `200` (success) / `500` (threw) for `mcp` rows.
2036
+ - `duration_ms` — wall-clock duration of the call.
2037
+
2038
+ Surfaced in the UI under the user menu → **Activity trace** (`ActivityTracePage`), with kind/user filters, 25-per-page pagination, and a refresh button.
2039
+
2040
+ ### GET `/nests/:id/trace` (nest owner / nest admin)
2041
+ The per-nest steward slice of the activity trace. Same response shape, pagination, and `kind` / `user` / `limit` / `offset` query params as `GET /admin/trace`, but every row is **always pinned to this nest server-side** — there is no `nest` param, and one supplied in the query is ignored, so a caller can never read another nest's activity through this endpoint.
2042
+
2043
+ Authorization mirrors the grants / steward-roster rule (`canManageStewards`): the nest owner, an `admin` collaborator, the license admin, or an `access.yaml` `super_admin` (open mode: always allowed). Read/write collaborators get `403`; callers with no access to the nest get `404`.
2044
+
2045
+ Surfaced in the UI from the nest view's overflow menu → **Activity trace** (owner/nest-admin only), which renders `ActivityTracePage` in nest-scoped mode (nest column hidden).
2046
+
2047
+ ---
2048
+
2049
+ ## Permission middleware quick-ref
2050
+
2051
+ For any `/nests/:nestId/...` request, the middleware computes:
2052
+ - `permission` = effective nest perm: `owner | admin | write | read | none`.
2053
+ - Required level by path:
2054
+ - `/collaborators`, `/visibility` → `admin`
2055
+ - `/reader-mode` → `admin` at the middleware, then the handler hard-gates on `isServerAdminUserId` (superadmin only)
2056
+ - Stewardship action paths (`/approve`, `/reject`, `/submit-review`, `/cancel-review`) → `read` (handler enforces fine-grained steward gating)
2057
+ - Other non-GET → `write`
2058
+ - GET → `read`
2059
+
2060
+ Returns 403 `Insufficient permissions` if not met. Open-mode (`AUTH_MODE=open`) treats everyone as `owner`.
2061
+
2062
+ License gate: any non-GET request OR any governance path requires a valid PromptOwl license (`/license/install`). 503 `License required` otherwise.
2063
+
2064
+ ---
2065
+
2066
+ ## Error shapes
2067
+
2068
+ | Status | When |
2069
+ |---|---|
2070
+ | 400 | Validation error (`{ "error": "..." }`) |
2071
+ | 401 | Auth required |
2072
+ | 403 | Permission denied (`Insufficient permissions`, governance steward mismatch) |
2073
+ | 404 | Nest / node / steward not found, or caller has no permission |
2074
+ | 409 | Conflict (e.g., key already exists, version conflict) |
2075
+ | 429 | Rate limit |
2076
+ | 503 | License missing |
2077
+
2078
+ ---
2079
+
2080
+ ## Quick test sequence (Postman)
2081
+
2082
+ 1. `POST /auth/login` → grab session cookie OR `POST /auth/keys` → copy `api_key`.
2083
+ 2. `POST /nests` → grab `nestId`.
2084
+ 3. `PATCH /nests/:nestId/settings` → `{ "stewardship_enabled": true }`.
2085
+ 4. `POST /nests/:nestId/nodes` → grab `node.id`.
2086
+ 5. `POST /nests/:nestId/stewards` → `{ scope:"document", email:"rev@x.com", role:"reviewer", nodePattern:"<node.id>" }`.
2087
+ 6. As `rev@x.com` → `POST /auth/register` (claims placeholder) → cookie stored.
2088
+ 7. `GET /nests` as reviewer → nest visible (proves auto-collab worked).
2089
+ 8. `GET /nests/:nestId/nodes` as reviewer → only the assigned doc visible (proves stewardship filter).
2090
+ 9. As original user → `POST /nests/:nestId/nodes/:nodeId/submit-review`.
2091
+ 10. As reviewer → `POST /nests/:nestId/nodes/:nodeId/approve` → 200.
2092
+ 11. `GET /nests/:nestId/nodes/:nodeId/versions` → confirm `resolvedBy` populated on the approved version.