@promptowl/contextnest-community 1.23.0 → 1.25.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 (91) hide show
  1. package/API.md +2195 -0
  2. package/CONFIGURATION.md +8 -2
  3. package/README.md +62 -10
  4. package/STEWARDSHIP.md +253 -0
  5. package/dist/{chunk-2YPY2HGZ.js → chunk-2KHM5C7V.js} +40 -5
  6. package/dist/{chunk-XYD6V2LH.js → chunk-32SZCH36.js} +28 -33
  7. package/dist/{chunk-QLXC6542.js → chunk-7FROMOZM.js} +69 -27
  8. package/dist/{chunk-MLQU4I5J.js → chunk-DTLMBF4W.js} +10 -8
  9. package/dist/{chunk-QWNXWRWO.js → chunk-JDAOA5KP.js} +67 -1
  10. package/dist/{chunk-UOMW7TT6.js → chunk-KPGNZLZL.js} +2 -2
  11. package/dist/{chunk-YVMSM7LS.js → chunk-LHWOJPFE.js} +7 -0
  12. package/dist/{chunk-5CVZMHHB.js → chunk-TI7HMP64.js} +99 -12
  13. package/dist/{client-LWDMNKX3.js → client-D44ZHDTJ.js} +1 -1
  14. package/dist/{engine-QFF2IA3G.js → engine-TER6FDBP.js} +3 -3
  15. package/dist/{external-edit-service-F7D3VSA2.js → external-edit-service-CAGENHDJ.js} +4 -4
  16. package/dist/{grants-service-TBGTCO5K.js → grants-service-2OTFS7EH.js} +3 -3
  17. package/dist/index.js +1120 -331
  18. package/dist/{migrations.postgres-APCVSYUE.js → migrations.postgres-23VJJJJX.js} +56 -5
  19. package/dist/{review-service-A7PNLWG5.js → review-service-4G2XUFS6.js} +7 -7
  20. package/dist/{stewardship-service-32UHIRQ5.js → stewardship-service-S22V2JKG.js} +6 -4
  21. package/dist/{version-service-RJ6CWZ3O.js → version-service-X4FAG2OZ.js} +4 -4
  22. package/dist/web3/assets/ActivityTracePage-D2iIbEph.js +1 -0
  23. package/dist/web3/assets/AgentDocsPage-Dkhgb1hk.js +1 -0
  24. package/dist/web3/assets/CollaboratorManager-Bo81D--Q.js +1 -0
  25. package/dist/web3/assets/CollaboratorsTab-DRCitQuX.js +1 -0
  26. package/dist/web3/assets/DocumentEditor-DV2WHYbj.js +36 -0
  27. package/dist/web3/assets/DocumentsTab-BUxBCN8o.js +6 -0
  28. package/dist/web3/assets/ExternalEditsTab-kIBimZyE.js +1 -0
  29. package/dist/web3/assets/MarkdownEditor-CsVusP2d.css +1 -0
  30. package/dist/web3/assets/MarkdownEditor-Dfk5uIE5.js +648 -0
  31. package/dist/web3/assets/NestPageHeader-yOK-OIYZ.js +1 -0
  32. package/dist/web3/assets/NestView-DwIagxwJ.js +68 -0
  33. package/dist/web3/assets/OverviewTab-DFOFMVyE.js +1 -0
  34. package/dist/web3/assets/PersonCombobox-D350WlrJ.js +1 -0
  35. package/dist/web3/assets/ReasonDialog-CN3ECAnQ.js +1 -0
  36. package/dist/web3/assets/ReviewActions-BXDTDPp1.js +6 -0
  37. package/dist/web3/assets/ReviewTab-CZewAYiz.js +1 -0
  38. package/dist/web3/assets/StewardsTab-AagNWcWS.js +1 -0
  39. package/dist/web3/assets/SubmitForReviewModal-BOZJxwRN.js +1 -0
  40. package/dist/web3/assets/alert-dialog-4SiLKlz8.js +7 -0
  41. package/dist/web3/assets/arrow-left-BAgIocLn.js +6 -0
  42. package/dist/web3/assets/backlinks-Cb8Uf8mw.js +24 -0
  43. package/dist/web3/assets/card-UDyMRcps.js +1 -0
  44. package/dist/web3/assets/chevron-left-XR4ReJ9Z.js +6 -0
  45. package/dist/web3/assets/circle-check-CSkZUEFK.js +6 -0
  46. package/dist/web3/assets/circle-x-in2o3aHU.js +6 -0
  47. package/dist/web3/assets/client-attribution-BraarYaP.js +1 -0
  48. package/dist/web3/assets/code-xml-pPQgdctI.js +6 -0
  49. package/dist/web3/assets/corner-down-right-CuxZWscX.js +6 -0
  50. package/dist/web3/assets/count-skeleton-DBWCDhRy.js +1 -0
  51. package/dist/web3/assets/dates-BCxbm4_q.js +1 -0
  52. package/dist/web3/assets/earth-CXOcThqn.js +6 -0
  53. package/dist/web3/assets/file-exclamation-point-CSphs1VD.js +6 -0
  54. package/dist/web3/assets/folder-input-BAwK9tFL.js +11 -0
  55. package/dist/web3/assets/folder-target-CUSWqImF.js +1 -0
  56. package/dist/web3/assets/index-A0_ymcqL.js +29 -0
  57. package/dist/web3/assets/index-B5hLclEq.js +389 -0
  58. package/dist/web3/assets/index-DrUAtoQM.css +1 -0
  59. package/dist/web3/assets/page-BHD4beun.js +1 -0
  60. package/dist/web3/assets/page-BSYUXYmO.js +11 -0
  61. package/dist/web3/assets/page-BYc2DIux.js +45 -0
  62. package/dist/web3/assets/page-BfibLg8e.js +1 -0
  63. package/dist/web3/assets/page-CGe8MtUu.js +9 -0
  64. package/dist/web3/assets/page-CIgPJmG6.js +2 -0
  65. package/dist/web3/assets/page-CUjuSs5Q.js +24 -0
  66. package/dist/web3/assets/page-Ck31nx9b.js +1 -0
  67. package/dist/web3/assets/page-Cpdj_11Y.js +1 -0
  68. package/dist/web3/assets/page-D0sN3GO6.js +1 -0
  69. package/dist/web3/assets/page-DEpH91X2.js +1 -0
  70. package/dist/web3/assets/page-l8-B7CGt.js +1 -0
  71. package/dist/web3/assets/page-mnSHmk6A.js +6 -0
  72. package/dist/web3/assets/page-title-BkqlUA0j.js +1 -0
  73. package/dist/web3/assets/page-xzYqkBUP.js +16 -0
  74. package/dist/web3/assets/play-B8xE9j5f.js +6 -0
  75. package/dist/web3/assets/refresh-cw-CsCtmWwX.js +6 -0
  76. package/dist/web3/assets/scroll-area-CbcH6yrZ.js +1 -0
  77. package/dist/web3/assets/scroll-area-dRWncRqa.css +1 -0
  78. package/dist/web3/assets/select-DrL0mpRN.js +6 -0
  79. package/dist/web3/assets/send-CvAgOUPG.js +6 -0
  80. package/dist/web3/assets/settings-EY4A_DYv.js +6 -0
  81. package/dist/web3/assets/share-2-vGmZUl90.js +6 -0
  82. package/dist/web3/assets/tag-Br_y1f00.js +11 -0
  83. package/dist/web3/assets/trash-2-B4hRXo-p.js +6 -0
  84. package/dist/web3/assets/triangle-alert-CsYGyfGZ.js +6 -0
  85. package/dist/web3/assets/user-plus-4CTYEkd3.js +6 -0
  86. package/dist/web3/assets/x-qxt1WrUi.js +6 -0
  87. package/dist/web3/assets/zap-BcsKR0ef.js +16 -0
  88. package/dist/web3/index.html +2 -2
  89. package/package.json +5 -3
  90. package/dist/web3/assets/index-BJ-LRNis.js +0 -1382
  91. package/dist/web3/assets/index-DPMEt-A_.css +0 -1
package/API.md ADDED
@@ -0,0 +1,2195 @@
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
+ ## Caller attribution (`client`)
18
+
19
+ `edited_by` names a person and a read names nobody, so neither answers *which
20
+ agent, in which session*. Every write body below, and every MCP tool call, takes
21
+ an optional `client` object that does ([spec §9.4](https://github.com/PromptOwl/ContextNest/blob/main/CONTEXT_NEST_SPEC.md)):
22
+
23
+ ```json
24
+ { "title": "API Design", "content": "…",
25
+ "client": { "agent": "claude-code", "session_id": "s-9f2", "run": 7 } }
26
+ ```
27
+
28
+ - `agent` and `session_id` are reserved; any other key is custom and is recorded
29
+ verbatim. Values are scalars (string / number / boolean).
30
+ - A write that creates a version records it **on that version** — it comes back
31
+ on `GET .../versions` and on MCP `context_versions` (in the prose and the
32
+ structured payload). Every path that seals a version takes it: create, edit,
33
+ approve, revert, and import (`POST /nests/import`, `POST /nests/unsynced/sync`,
34
+ which stamp every row they seed), plus `POST /review-queue/bulk`, whose one
35
+ block rides every approval in the batch. `POST .../submit-review` seals it
36
+ into the engine's own version chain (`history.yaml`), which is the entry that
37
+ submission appends. Every MCP tool call, and every REST
38
+ call that accepts `client`, records it on the activity trace
39
+ (`GET /admin/trace`, `GET /nests/:nestId/trace`, where it comes back as a
40
+ parsed `client` object) — which is where a call that seals no version is
41
+ attributed: a **read**, and a **rejection**, which commits no content.
42
+ - **Entirely optional.** No endpoint requires it and an unattributed call is a
43
+ valid call. A call that carries none stores nothing rather than an empty
44
+ object — "not attributed" and "attributed to nobody" are different claims.
45
+ - **A label, not an identity.** It is never authenticated, never used to
46
+ authorize, and never an input to a hash chain, so histories written before the
47
+ field existed keep verifying byte for byte. `editedBy` remains the
48
+ authoritative authoring record.
49
+ - **Bounded**, because an untrusted caller writes it into an append-only audit
50
+ trail: values ≤512 chars, ≤16 custom keys, scalars only → `400` otherwise. A
51
+ near-miss on a reserved key (`sessionId`, `Agent`) is refused rather than
52
+ filed as a custom key, which would look attributed and not be.
53
+ - The server fills what the transport already knows, **per key, under** anything
54
+ the caller sent. Precedence is caller > env > connection: on MCP the
55
+ connection means the `initialize` handshake's `clientInfo.name` and the
56
+ transport's session id; behind that sits the label the operator gave the API
57
+ key. `CONTEXTNEST_AGENT` / `CONTEXTNEST_SESSION_ID` are the env layer;
58
+ `CONTEXTNEST_NO_ATTRIBUTION=1` derives nothing (a `client` the caller sent
59
+ explicitly is still honoured). See `CONFIGURATION.md`.
60
+ - Note the `/mcp` endpoints are **stateless** — one server per request, no
61
+ session issued — so a handshake's `clientInfo` is not in scope by the time a
62
+ tool call arrives, and `agent` comes from the key label in practice. An agent
63
+ that wants to name itself exactly should send `client` rather than rely on
64
+ what the connection happens to report.
65
+
66
+ - **Treat a recorded `client` as untrusted text when you read it back.** The
67
+ values are whatever the caller sent, stored verbatim and unescaped by design —
68
+ an audit record that rewrites what the caller said is worth less than none —
69
+ and `context_versions` prints `agent` into the prose an LLM client reads
70
+ (`[via claude-code]`). That is the same standing as every other caller-written
71
+ string this server serves back: document titles, bodies, change notes and
72
+ review notes all reach an agent's context the same way. Bounds (≤512 chars,
73
+ scalars) cap the size, not the content. Render it as data, never as
74
+ instructions, and the usual rule applies to anything downstream: escape at the
75
+ point of use. This server's own UI does — React escapes it as a text node, and
76
+ nothing interpolates it into SQL (`client_json` is always a bound parameter).
77
+
78
+ Distinct from `metadata`, which is frontmatter: `metadata` describes the
79
+ document, `client` describes the call that touched it.
80
+
81
+ ---
82
+
83
+ ## 1. Auth
84
+
85
+ ### POST `/auth/register`
86
+ 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.
87
+
88
+ Body:
89
+ ```json
90
+ { "email": "user@x.com", "password": "secret123", "name": "Optional Name" }
91
+ ```
92
+
93
+ Behavior:
94
+ - Email not in DB → create user → 201 + session cookie.
95
+ - 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.
96
+ - Email in DB with `is_invited = 0` → 400 `Email already registered`.
97
+
98
+ Response (201):
99
+ ```json
100
+ { "user": { "id": "uuid", "email": "...", "name": null, "is_admin": false } }
101
+ ```
102
+
103
+ Rate limit: per-IP.
104
+
105
+ ### POST `/auth/login`
106
+ Body:
107
+ ```json
108
+ { "email": "user@x.com", "password": "secret123" }
109
+ ```
110
+ 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).
111
+
112
+ ### POST `/auth/logout`
113
+ Clears session cookie. 200.
114
+
115
+ ### POST `/auth/keys` (auth required)
116
+ 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.
117
+
118
+ `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.
119
+ ```json
120
+ { "label": "cli", "nest_id": "optional — scope the key to one nest" }
121
+ ```
122
+ Response (201):
123
+ ```json
124
+ { "api_key": "cnst_...", "id": "uuid", "key_prefix": "cnst_abc123...", "label": "cli" }
125
+ ```
126
+ **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.
127
+
128
+ ### POST `/auth/keys/rotate` (auth required)
129
+ Replaces **one** key and returns its new plaintext; the caller's other keys are untouched.
130
+
131
+ `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.
132
+
133
+ 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.
134
+ ```json
135
+ { "key_id": "uuid", "label": "optional", "nest_id": "optional" }
136
+ ```
137
+
138
+ ### GET `/auth/keys` (auth required)
139
+ Lists the caller's keys — id, prefix, label, `nest_id`, `created_at`, `last_used_at` — oldest first. Never returns plaintext.
140
+
141
+ ### DELETE `/auth/keys/:keyId` (auth required)
142
+ Revoke one key. 404 if the id isn't the caller's. Anything still using it stops working immediately.
143
+
144
+ ### POST `/auth/password` (auth required)
145
+ ```json
146
+ { "current_password": "...", "new_password": "..." }
147
+ ```
148
+
149
+ ### PATCH `/auth/profile` (auth required)
150
+ 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`.
151
+ ```json
152
+ { "name": "Alan" }
153
+ ```
154
+ Response: `{ "ok": true, "name": "Alan" }`
155
+
156
+ ### POST `/auth/invite` (admin only)
157
+ 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.
158
+ ```json
159
+ { "email": "teammate@x.com" }
160
+ ```
161
+ - New email → user row with `is_invited = 1` and a temporary password.
162
+ - Existing, not-yet-claimed email (`is_invited = 1`, hasn't logged in) → refreshes the temp password.
163
+ - Existing, claimed email (has logged in or set their own password, `is_invited = 0`) → `409`; use Reset password instead.
164
+
165
+ `is_invited` clears to `0` on the teammate's first successful login (or when they set their own password).
166
+
167
+ Response (201):
168
+ ```json
169
+ {
170
+ "temporary_password": "...",
171
+ "user": { "id": "...", "email": "..." },
172
+ "message": "Share this temporary password securely — it won't be shown again."
173
+ }
174
+ ```
175
+
176
+ ### GET `/auth/teammates` (admin only)
177
+ Returns all users + key counts. **Sorted: admins first, then `created_at DESC`.**
178
+ ```json
179
+ {
180
+ "teammates": [
181
+ {
182
+ "id": "uuid",
183
+ "email": "...",
184
+ "name": null,
185
+ "is_admin": true,
186
+ "is_invited": 0,
187
+ "key_count": 1,
188
+ "last_active": "2026-05-15T10:00:00Z"
189
+ }
190
+ ],
191
+ "pending_stewards": ["unregistered@x.com"]
192
+ }
193
+ ```
194
+ `is_invited = 1` → user was placeholder-created, has not yet claimed the account.
195
+
196
+ ### POST `/auth/admin/reset-password/:userId` (admin only)
197
+ 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.
198
+ ```json
199
+ { "password": "optional, min 8 chars" }
200
+ ```
201
+ 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.
202
+
203
+ ### DELETE `/auth/users/:userId` (admin only)
204
+ 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.
205
+
206
+ ### POST `/auth/promptowl`
207
+ 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.
208
+
209
+ ### GET `/auth/admin-status`
210
+ Returns whether the caller is the license admin + license metadata.
211
+
212
+ ### POST `/auth/device` / GET `/auth/device/poll`
213
+ Device-flow login. Also gated by `PROMPTOWL_SIGN_IN_GATE`.
214
+
215
+ ### GET `/auth/sso`
216
+ 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).
217
+
218
+ ### GET `/auth/oidc/login`
219
+ 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.
220
+
221
+ 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.
222
+
223
+ ### GET `/auth/oidc/callback`
224
+ 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.
225
+
226
+ ### GET `/auth/oidc/logout`
227
+ 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.
228
+
229
+ 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.
230
+
231
+ ### POST `/auth/token-exchange`
232
+ 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)`.
233
+
234
+ 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.
235
+
236
+ 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`.
237
+
238
+ `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`.
239
+
240
+ 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.
241
+
242
+ ---
243
+
244
+ ## 2. Nests
245
+
246
+ A nest = a context vault (folder of markdown documents + governance state).
247
+
248
+ ### GET `/nests`
249
+ 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`). Nests reachable through `org`/`public` visibility alone are included too and carry `discovered: true` (owned and shared rows carry `false`) — the dashboard's default *My nests* scope leaves them out.
250
+ ```json
251
+ { "nests": [{ "id": "...", "user_id": "...", "name": "...", "slug": "...", "visibility": "private", "created_at": "..." }] }
252
+ ```
253
+
254
+ `?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`.
255
+
256
+ ### POST `/nests`
257
+ ```json
258
+ { "name": "My Nest", "description": "optional" }
259
+ ```
260
+ Response (201): `{ "nest": { ... } }`
261
+
262
+ ### GET `/nests/:nestId`
263
+ ```json
264
+ { "nest": { ... }, "permission": "owner" | "admin" | "write" | "read" }
265
+ ```
266
+ 404 if caller has no permission.
267
+
268
+ ### DELETE `/nests/:nestId`
269
+ Owner only. Permanent — documents, versions and governance state all go.
270
+
271
+ ### POST `/nests/:nestId/archive`
272
+ Owner only (same gate as delete, license-admin caretaker branch included). Puts the nest away without deleting anything: `{ "archived": true }`.
273
+
274
+ 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.
275
+
276
+ **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.
277
+
278
+ 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.
279
+
280
+ 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.
281
+
282
+ ### POST `/nests/:nestId/restore`
283
+ Owner only. `{ "restored": true }`. Puts everything back exactly as it was — same documents, versions, stewards, links.
284
+
285
+ 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.
286
+
287
+ ### POST / DELETE `/nests/:nestId/pin`
288
+ The caller's own bookmark on this nest: `{ "pinned": true }` / `{ "pinned": false }`. Idempotent in both directions.
289
+
290
+ **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.
291
+
292
+ 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.
293
+
294
+ 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.
295
+
296
+ ### GET `/nests/:nestId/settings`
297
+ ```json
298
+ {
299
+ "stewardship_enabled": false,
300
+ "allow_self_approve": false,
301
+ "prime_only_review": false,
302
+ "prime_tags": ["prime-document"],
303
+ "creator_is_reviewer": false
304
+ }
305
+ ```
306
+
307
+ ### PATCH `/nests/:nestId/settings` (admin/owner)
308
+ ```json
309
+ { "stewardship_enabled": true, "prime_only_review": true, "prime_tags": ["prime-document"] }
310
+ ```
311
+
312
+ Every field is optional; only the ones present are changed.
313
+
314
+ - `allow_self_approve` — owner/admin writes publish immediately instead of drafting.
315
+ - `prime_only_review` — in a governed nest, only **prime** documents need approval; everything else self-publishes. Off by default.
316
+ - `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`.
317
+ - `creator_is_reviewer` — in a governed nest, whoever creates a document becomes its document-scope `reviewer` and v1 publishes immediately; later changes go through review as usual (the creator approves teammates' changes, another reviewer approves the creator's). Prime documents still draft. Future creates only. Off by default. See `STEWARDSHIP.md → Creators review their own documents`.
318
+
319
+ 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.
320
+
321
+ ### PATCH `/nests/:nestId/visibility` (admin)
322
+ ```json
323
+ { "visibility": "public", "acknowledge_public": true }
324
+ ```
325
+ - **`visibility`** is one of:
326
+ | value | who can read without being added |
327
+ |---|---|
328
+ | `private` | nobody — owner, collaborators and stewards only |
329
+ | `org` | any **authenticated** user of this deployment (a self-hosted install is one organization). Anonymous callers get nothing. |
330
+ | `public` | anyone, including anonymous callers |
331
+ `org` and `public` readers see **approved content only** — drafts and pending versions stay with collaborators and stewards.
332
+ 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.
333
+ - **Outside-org publish guardrail.** Setting `visibility: "public"` **requires** `acknowledge_public: true`. `org` does not on a single-organization server — it stays inside the deployment, so it crosses no organizational boundary. On a **shared deployment** (`SHARED_DEPLOYMENT=true` / Settings → General → *This server hosts several organizations* — the PromptOwl-hosted service) the deployment *is* several organizations, so `org` requires the same `acknowledge_public: true` and the `warning` names that boundary. Without the acknowledgement the request is rejected `409`:
334
+ ```json
335
+ {
336
+ "error": "Publishing this nest requires acknowledgement.",
337
+ "requires_acknowledgement": true,
338
+ "warning": "This makes the nest readable by anyone on the internet, outside your organization."
339
+ }
340
+ ```
341
+ On a shared deployment, `org` without the acknowledgement is rejected the same way, with its own warning:
342
+ ```json
343
+ {
344
+ "error": "Opening this nest to the organization requires acknowledgement.",
345
+ "requires_acknowledgement": true,
346
+ "warning": "This server is shared: 'Organization' makes the nest readable by every account on this server, including other organizations — not just yours."
347
+ }
348
+ ```
349
+ Going back to `private` needs no acknowledgement. `GET /health` reports `shared_deployment` so a client can word its own confirmation the same way.
350
+
351
+ ### PATCH `/nests/:nestId/reader-mode` (server-admin / superadmin only)
352
+ 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`.
353
+ ```json
354
+ { "enabled": true, "home_node": "welcome" }
355
+ ```
356
+ - `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.
357
+ - `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`.
358
+ - At least one of `enabled` / `home_node` must be present (`400` otherwise).
359
+ - **`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.
360
+
361
+ Rejections are the standard error shape:
362
+ ```json
363
+ { "error": "home_node must be a string or null" }
364
+ ```
365
+
366
+ **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.
367
+
368
+ Response (200) echoes the persisted row:
369
+ ```json
370
+ { "reader_mode": 1, "reader_home_node": "welcome" }
371
+ ```
372
+ The two additive columns (`nests.reader_mode`, `nests.reader_home_node`) also surface on `GET /nests/:nestId` via the `SELECT *` nest payload.
373
+
374
+ ### GET `/nests/unsynced` (server-admin only outside open mode)
375
+ 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`).
376
+
377
+ Response (200):
378
+ ```json
379
+ {
380
+ "folders": [
381
+ {
382
+ "name": "nodes/architecture",
383
+ "label": "architecture",
384
+ "mdCount": 12,
385
+ "sizeBytes": 48230
386
+ }
387
+ ]
388
+ }
389
+ ```
390
+ - `name` — path relative to `DATA_ROOT` (slash-separated; pass back verbatim to `/sync`).
391
+ - `label` — leaf folder name (display only).
392
+ - `mdCount` / `sizeBytes` — totals across all `.md` files under the folder, recursive.
393
+
394
+ 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.
395
+
396
+ ### POST `/nests/unsynced/sync` (server-admin only outside open mode)
397
+ 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.
398
+
399
+ Body:
400
+ ```json
401
+ { "name": "nodes/architecture" }
402
+ ```
403
+ - `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.
404
+
405
+ Response (201):
406
+ ```json
407
+ {
408
+ "nest": { "id": "...", "name": "architecture", "slug": "architecture", "...": "..." },
409
+ "documents": 12
410
+ }
411
+ ```
412
+ - 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.
413
+ - `documents` — count of governance-registered docs.
414
+
415
+ Errors:
416
+ - `400 Invalid folder name` — empty, contains `\`, traversal segment, dot-prefixed segment, or `node_modules`.
417
+ - `400 Folder is not eligible for sync` — reserved top-level (e.g. `nests/`).
418
+ - `400 Folder has no markdown to sync` — empty folder.
419
+ - `404 Folder not found` — path doesn't exist under `DATA_ROOT`.
420
+ - `403 Only the server admin can sync folders` — non-admin in non-open mode.
421
+
422
+ 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.
423
+
424
+ ### DELETE `/nests/unsynced/sync` (server-admin only outside open mode)
425
+ 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.
426
+
427
+ Body:
428
+ ```json
429
+ { "name": "nodes/architecture" }
430
+ ```
431
+ - `name` — required, as returned by `GET /nests/unsynced`. In the body (not a path-param) because leaf paths contain slashes.
432
+
433
+ Response (200):
434
+ ```json
435
+ { "ok": true }
436
+ ```
437
+
438
+ Errors:
439
+ - `400 name is required` — missing/blank name.
440
+ - `400` — unsafe/reserved/hidden name (traversal, `nests`, dot-prefixed, `node_modules`).
441
+ - `404 Folder not found` — path doesn't exist under `DATA_ROOT`.
442
+ - `403 Only the server admin can delete folders` — non-admin in non-open mode.
443
+
444
+ ---
445
+
446
+ ## 3. Collaborators (direct nest sharing)
447
+
448
+ Separate from stewardship — this grants raw nest access. Stewardship layers governance on top.
449
+
450
+ ### GET `/nests/:nestId/collaborators` (write+)
451
+ ```json
452
+ { "collaborators": [{ "id": "...", "user_id": "...", "email": "...", "permission": "read" }] }
453
+ ```
454
+ Write-tier (not read) so a public nest's read tier can't enumerate member emails.
455
+
456
+ ### POST `/nests/:nestId/collaborators` (write+)
457
+ ```json
458
+ { "email": "c@x.com", "permission": "read" }
459
+ ```
460
+ - `permission` ∈ `read` / `write` / `admin`.
461
+ - **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.
462
+ - Auto-creates placeholder user (`is_invited = 1`) if email unknown.
463
+
464
+ ### PATCH `/nests/:nestId/collaborators/:collabId` (admin)
465
+ ```json
466
+ { "permission": "write" }
467
+ ```
468
+ Changing an existing collaborator's level stays admin-only.
469
+
470
+ ### DELETE `/nests/:nestId/collaborators/:collabId` (admin)
471
+ Removing a collaborator stays admin-only.
472
+
473
+ ### GET `/nests/:nestId/mentionable` (read+, members only)
474
+ ```json
475
+ { "people": [{ "email": "owner@x.com", "name": "Olive Owner" }] }
476
+ ```
477
+ 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`).
478
+
479
+ 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).
480
+
481
+ ### GET `/people/suggest?q=&limit=` (auth required)
482
+ ```json
483
+ { "people": [{ "email": "c@x.com", "name": "Carl Colleague", "source": "nest" }] }
484
+ ```
485
+ 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.
486
+
487
+ 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.
488
+
489
+ `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.
490
+
491
+ ---
492
+
493
+ ## 3a. Share grants (one document / one folder)
494
+
495
+ 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.
496
+
497
+ 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.
498
+
499
+ **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.
500
+
501
+ 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.
502
+
503
+ Managing grants is **owner / nest admin / server admin** (`canManageStewards` — same authority as the steward roster). Everyone else gets `403`.
504
+
505
+ ### GET `/nests/:nestId/grants`
506
+ ```json
507
+ { "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": "..." }] }
508
+ ```
509
+ The whole nest's sharing roster, annotated with the grantee's email.
510
+
511
+ ### POST `/nests/:nestId/grants`
512
+ ```json
513
+ { "target_type": "document", "target": "nodes/win-loss-log", "email": "guest@x.com", "role": "read" }
514
+ ```
515
+ - `target_type` ∈ `document` / `folder`; `role` ∈ `read` / `write` (default `read`).
516
+ - `target` is a node id (`nodes/win-loss-log`) or a folder prefix (`nodes/gtm/deals`).
517
+ - A folder target must name a **subfolder**: a bare `nodes` root would match every node and silently share the whole nest → `400`.
518
+ - Re-granting the same `(target_type, target, user)` updates the role rather than duplicating.
519
+ - Auto-creates a placeholder user (`is_invited = 1`) if the email is unknown, same as collaborator invites.
520
+ - `201 { grant }`.
521
+
522
+ ### DELETE `/nests/:nestId/grants/:id`
523
+ `{ "deleted": true }`, or `404` if the grant isn't in this nest.
524
+
525
+ 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.
526
+
527
+ 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.
528
+
529
+ ---
530
+
531
+ ## 3b. Teams (group sharing)
532
+
533
+ A **team** is a reusable, owner-managed group of users. Each member carries a
534
+ **role** (`admin` / `editor` / `viewer`) set on the membership and applied
535
+ uniformly on every nest the team is shared to. Sharing a team onto a nest
536
+ records a `{teamId, teamName}` ref on `nests.shared_teams` (no join table);
537
+ every member then resolves to their own role there, combined highest-wins with
538
+ any direct collaborator / steward grant.
539
+
540
+ Member role → access mapping:
541
+
542
+ | role | nest permission | governance role |
543
+ |------|-----------------|-----------------|
544
+ | `viewer` | `read` | viewer |
545
+ | `editor` | `write` | editor |
546
+ | `admin` | `admin` | admin |
547
+
548
+ Sharing a team whose members carry a governance role (`viewer`/`editor`) turns
549
+ `stewardship_enabled` on for the nest (parity with assigning an individual
550
+ steward). An `admin`-only team is collaborator-style and does not.
551
+
552
+ ### Team management (global)
553
+
554
+ Auth is the shared session/key auth. Management is gated by the caller's role on
555
+ the team (the server/license admin may manage any team): **add people** =
556
+ editor/admin members + owner (escalation-capped — can't grant above your own
557
+ role); **rename / change-role / remove** = admin members + owner; **delete** =
558
+ owner only. Unregistered member emails mint a placeholder user (`is_invited = 1`),
559
+ same as collaborator/steward invites.
560
+
561
+ - **POST `/teams`** — `{ "name": "Platform" }` → `201 { team }`. Any user may
562
+ create a team (they become its owner).
563
+ - **GET `/teams`** — teams the caller owns **or is a member of** → `{ teams: [...] }`.
564
+ Each member is enriched with `registered` (real account vs invited placeholder)
565
+ and the team with `owner_email`.
566
+ - **GET `/teams/:teamId`** — owner, a member, or the server admin may view →
567
+ `{ team: { id, name, owner_id, owner_email, members: [{ userId, email, role, registered }], ... } }`.
568
+ - **PATCH `/teams/:teamId`** — `{ "name": "..." }` rename (admin/owner). Also
569
+ refreshes the cached `teamName` in every nest that shares this team.
570
+ - **DELETE `/teams/:teamId`** — delete (owner/server-admin); strips the team's
571
+ ref from every nest's `shared_teams`.
572
+ - **POST `/teams/:teamId/members`** — `{ "email"|"user_id", "role" }` add a
573
+ member (`role` ∈ `admin`/`editor`/`viewer`) → `201 { team }`. Editor/admin
574
+ members + owner, capped to the caller's own role. Duplicate member → `409`;
575
+ invalid role → `400`; above your level → `403`.
576
+ - **PATCH `/teams/:teamId/members/:userId`** — `{ "role": "..." }` change role
577
+ (admin/owner, capped).
578
+ - **DELETE `/teams/:teamId/members/:userId`** — remove a member (admin/owner);
579
+ their access to every shared nest is revoked immediately (no per-user rows).
580
+
581
+ A team-shared nest also appears on each member's dashboard (`GET /nests`) with
582
+ their role, so they can open it and act per their role.
583
+
584
+ ### PromptOwl team import (`/teams/promptowl/*`)
585
+
586
+ Copy the teams the caller owns in PromptOwl into this server. Both routes are
587
+ capped at 10 requests per IP per 15 minutes.
588
+
589
+ **Only a PromptOwl sign-in can import.** Signing in with PromptOwl parks the
590
+ device token in an httpOnly cookie (`cnst_po_token`, `Path=/teams`, lifetime of
591
+ the session), and these routes read it **from there and nowhere else** — a token
592
+ in the request body is ignored, so a user who signed in another way (SSO, OIDC,
593
+ password) cannot import by supplying one. Hiding the UI is the other half of
594
+ this rule, not the enforcement.
595
+
596
+ The token is **never written to the database**: PromptOwl hands it over exactly
597
+ once, and a self-hosted server holding live credentials for someone else's SaaS
598
+ is a far worse liability than a cookie that dies with the session. `Path=/teams`
599
+ means the browser only attaches it to these routes. It is scoped upstream to
600
+ `user:read` + `teams:read`, so it cannot act as the user in PromptOwl (no agent
601
+ chat, no credit spend) even if it leaks.
602
+
603
+ Sign-in also sets `cnst_po_session=1` — a readable, secret-free marker so the
604
+ SPA knows whether to offer the PromptOwl Teams tab. Both cookies are cleared on
605
+ logout.
606
+
607
+ **`428 Precondition Required`** with `{ code: "promptowl_connect_required" }`
608
+ means "no PromptOwl session — sign in with PromptOwl". It is deliberately **not**
609
+ `401`: the caller is authenticated here, and a `401` would read to the SPA as an
610
+ expired session and bounce them to the login screen. The same 428 is returned
611
+ when PromptOwl rejects the token (revoked upstream), and the cookie is cleared
612
+ so a dead token isn't replayed forever.
613
+
614
+ An imported team carries `source: "promptowl"` and `external_id` (its PromptOwl
615
+ id); a team created here has `null` for both. `(owner_id, source, external_id)`
616
+ is the import key — a re-import updates that same team rather than duplicating
617
+ it, and survives a rename on either side. Two users importing the same PromptOwl
618
+ team each get their own local team.
619
+
620
+ - **GET `/teams/promptowl`** — `{ teams: [{ id, name, owned, your_role, source,
621
+ enterprise, updated_at, members: [{ email, role }] }] }`. Read-only. Returns
622
+ every team the caller owns **or belongs to** in PromptOwl; `your_role` is
623
+ their role on it there. No PromptOwl session → `428`; unreachable PromptOwl →
624
+ `400`.
625
+ - **POST `/teams/promptowl/sync`** — `{ "team_ids": [...] }` →
626
+ `{ results: [{ team_id, external_id, name, created, added, updated, removed }] }`,
627
+ where `added`/`updated`/`removed` are member emails. A `team_id` the caller has
628
+ nothing to do with upstream → `400` (unrelated and nonexistent teams are
629
+ refused identically, so the error can't be used to probe which ids exist).
630
+
631
+ **Importing requires `Owner` or `Editor` upstream** → otherwise `403`, and the
632
+ whole batch is refused before any writes. The local copy is owned by the
633
+ importer — free rein to rename it, rewrite its roster, and share it onto a
634
+ nest. A view-only member of the upstream team holds none of that and must not
635
+ gain it by copying the team here; that's the same escalation
636
+ `shareTeamWithNest` already refuses. PromptOwl's `Viewer`, its default `User`,
637
+ and any unrecognized role all count as no authority.
638
+
639
+ **PromptOwl wins.** The local roster is reconciled to match: roles are reset
640
+ and members no longer in the PromptOwl team are **removed**, including ones
641
+ added here by hand. Roles map `Owner→admin`, `Editor→editor`, `Viewer→viewer`,
642
+ and `User`→`viewer` (as does any unrecognized role — an unknown role must never
643
+ widen access). The importer is the local team's owner and is not written into
644
+ the roster. Unregistered member emails mint placeholders exactly as
645
+ `POST /teams/:teamId/members` does.
646
+
647
+ ### Nest team-share (`/nests/:nestId/teams`)
648
+
649
+ - **GET** `(write+)` — teams shared onto the nest, enriched with members:
650
+ `{ teams: [{ teamId, teamName, members: [{ userId, email, role }] }] }`.
651
+ Write-tier (like the collaborator roster) so a read tier can't enumerate emails.
652
+ - **POST** `(admin)` — `{ "team_id": "..." }` share a team → `201 { teams }`.
653
+ Already shared → `409`. The caller must also be **editor+ on the team**
654
+ (editor/admin/owner, or server admin); a view-only member sharing → `403`, so
655
+ a viewer can't project a whole team onto a nest they own.
656
+ - **DELETE `/nests/:nestId/teams/:teamId`** `(admin)` — unshare → `{ teams }`.
657
+
658
+ ---
659
+
660
+ ## 4. Nodes (documents)
661
+
662
+ `nodeId` = relative path without `.md`, e.g. `nodes/api-design`.
663
+
664
+ ### GET `/nests/:nestId/nodes`
665
+ Lists all readable docs.
666
+ - When `stewardship_enabled = true`, list is filtered by `filterAccessible` — non-steward users only see what they're assigned to.
667
+
668
+ Optional query params, applied before the per-node enrichment:
669
+
670
+ | Param | Notes |
671
+ |---|---|
672
+ | `type` | Node type. A doc with no `type:` counts as `document`. |
673
+ | `tag` | Leading `#` optional, case-insensitive. |
674
+ | `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. |
675
+ | `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. |
676
+ | `limit` | Max nodes to return. A non-numeric value is ignored, not an error. |
677
+ | `folder` | Present (even as `""`) → that one folder level only, for a lazily-expanded document tree. Absent → the whole nest. |
678
+ | `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. |
679
+ ```json
680
+ {
681
+ "count": 1,
682
+ "nodes": [{
683
+ "id": "nodes/foo",
684
+ "title": "Foo",
685
+ "type": "document",
686
+ "tags": ["#api"],
687
+ "status": "draft" | "pending_review" | "approved" | "rejected",
688
+ "version": 1,
689
+ "author": "creator@x.com",
690
+ "collaborators": ["editor@x.com", "grantee@x.com"],
691
+ "created_at": "...",
692
+ "updated_at": "...",
693
+ "content": "# md body — only with ?content=1",
694
+ "schedule": "0 6 * * *"
695
+ }]
696
+ }
697
+ ```
698
+ - `schedule` is present only on runnable nodes (`type` `agent`/`skill`); omitted otherwise.
699
+ - `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.
700
+ - 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.
701
+
702
+ ### POST `/nests/:nestId/nodes`
703
+ ```json
704
+ {
705
+ "title": "Foo",
706
+ "content": "# Foo\n\nbody...",
707
+ "tags": ["api", "v2"],
708
+ "type": "document",
709
+ "scope": "team",
710
+ "status": "draft",
711
+ "folder": "gtm/deals",
712
+ "schedule": "0 6 * * *",
713
+ "client": { "agent": "claude-code", "session_id": "s-9f2" }
714
+ }
715
+ ```
716
+ - Slug derived from title → `nodes/<slug>`.
717
+ - 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`.
718
+ - `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)).
719
+ - 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).
720
+ - 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`.
721
+ - If stewardship enabled → status defaults `draft`. Else `approved`.
722
+ - Optional `client` — caller attribution, recorded on the version this create seals. See [Caller attribution](#caller-attribution-client).
723
+ - Auto-syncs tag index for steward resolution.
724
+
725
+ Response (201): `{ "node": { ... }, "stewards": [...] }`.
726
+
727
+ ### GET `/nests/:nestId/nodes/:nodeId`
728
+ Full node content. 403 if no steward access (when stewardship on).
729
+ Add `?format=markdown` to receive `text/markdown` (frontmatter + body) instead
730
+ of JSON — see [§8 export](#get-nestsnestidexportformatmarkdown--llm-pointed-plain-text-view).
731
+
732
+ **Direct raw fetch** — `?format=raw` returns the node **body** as plain text
733
+ (no JSON envelope, no synthesized frontmatter):
734
+
735
+ | Param / header | Effect |
736
+ |---|---|
737
+ | `?format=raw` | Raw node body as text; Content-Type per node type (table below). |
738
+ | `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. |
739
+ | `?frontmatter=1` | Only meaningful with raw: prepends the node's YAML frontmatter block exactly as stored, above the body. |
740
+
741
+ Content-Type by node `type`:
742
+
743
+ | Node type | Content-Type |
744
+ |---|---|
745
+ | `document`, `agent`, `skill` (and any other/unset type) | `text/markdown; charset=utf-8` |
746
+ | `artifact` | `text/html; charset=utf-8` |
747
+ | `tool`, `table` | `text/plain; charset=utf-8` |
748
+
749
+ Access is identical to the JSON GET — same middleware, grants, and
750
+ public-reader semantics. Public readers get the **approved snapshot** body
751
+ (and, with `frontmatter=1`, that snapshot's stored frontmatter); callers
752
+ without read access get the same 403/404 JSON error as the JSON GET, never
753
+ body bytes. Without `format`/`Accept` the response is unchanged JSON.
754
+
755
+ ### POST `/nests/:nestId/nodes/:nodeId/revert`
756
+ Restore an earlier version by writing its content as a **new** version (history is preserved).
757
+ ```json
758
+ { "targetVersion": 3 }
759
+ ```
760
+ 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).
761
+
762
+ ### POST `/nests/:nestId/nodes/:nodeId/discard`
763
+
764
+ Throw away the unreviewed work on a document. No body.
765
+
766
+ Response: `{ "ok": true, "deleted": <bool>, "version": <sealed|null> }`.
767
+
768
+ Two outcomes, and which one you get depends on the document:
769
+ - 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.
770
+ - It was **never** published → there is nothing to fall back to, so the document is deleted, exactly as `DELETE /nodes/:nodeId` would. `deleted: true`.
771
+
772
+ 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.
773
+
774
+ `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).
775
+
776
+ ### PATCH `/nests/:nestId/nodes/:nodeId`
777
+ Headers (optional): `X-Base-Version: 3` — server returns 409 on conflict.
778
+ ```json
779
+ {
780
+ "title": "...",
781
+ "content": "...",
782
+ "tags": ["..."],
783
+ "changeNote": "fixed typo",
784
+ "schedule": "0 7 * * *",
785
+ "client": { "agent": "claude-code", "session_id": "s-9f2" }
786
+ }
787
+ ```
788
+ Requires nest write permission.
789
+ - `client` — caller attribution, recorded on the version this edit seals. See [Caller attribution](#caller-attribution-client).
790
+ - `schedule` — runnable types (`agent`/`skill`) only; sent for any other type → `400`. Send `""` to clear it.
791
+ - `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`.
792
+ - `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.
793
+
794
+ ### DELETE `/nests/:nestId/nodes/:nodeId`
795
+ Write permission. Hidden in UI for reviewer/viewer; server still enforces.
796
+
797
+ 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).
798
+
799
+ 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).
800
+
801
+ ### POST `/nests/:nestId/nodes/pdf`
802
+ Upload a PDF as a governed `pdf` document. Multipart: `file` (required), optional `title` (default: the file name), `folder`, `tags` (repeat the field or comma-separate), `nodeId`, `changeNote`. Write permission (nest-scope steward editors too, as for `POST /nodes`).
803
+
804
+ The engine's `context_import_pdf` extracts the text — the node body, one `<!-- page N -->` marker per page — and stores the PDF beside the node as `<id>.pdf`, bound by sha256 in the node's `pdf:` frontmatter block. Governance is exactly a create's: in a governed nest the upload lands as a draft (or publishes when a create by that person would); without stewards it publishes.
805
+
806
+ - `nodeId` of an existing `pdf` node → the PDF is replaced as a **new version** of that node (same review lock, draft/publish rule and version rows as `PATCH`). The prior binary stays in history. Identical bytes are a no-op. A replace is two engine writes (stage the new PDF, then the same update/publish a text edit makes); if the second fails, the node's previous file and PDF are put back before the error is returned.
807
+ - `400` — no `file`, not a PDF (checked by the `%PDF-` header, not the extension), an encrypted/unreadable PDF, or `nodeId` names a node that is not a pdf. `413` — larger than `PDF_MAX_MB` (default 25 MB). Nothing is written on any refusal.
808
+ - A PDF with no text layer (a scan) imports with an empty body and `pdf.text_layer: false`; there is no OCR.
809
+ - Returns `201` `{ node, version, stewards? }` on create, `200` `{ node, version }` on replace. `node.pdf` is the `pdf:` block (`file`, `sha256`, `bytes`, `pages`, `text_layer`, `extractor`, `extractor_version`, `extracted_at`).
810
+
811
+ A pdf node's text is read-only: `PATCH` with a `content`/`append` that changes it → `409` (re-upload instead; echoing the stored body back is accepted). A pdf node can't be converted to another type or another type to `pdf` (`400`), can't be moved to another folder yet (`400`), and `POST /nodes` with `type: "pdf"` → `400`. `POST …/revert` re-imports the target version's PDF; `POST …/discard` restores the sealed version's PDF (the response carries a `warning` if that binary can't be restored); `DELETE` removes the binary with the node.
812
+
813
+ ### GET `/nests/:nestId/nodes/:nodeId/pdf`
814
+ The PDF binary, gated exactly like `GET /nodes/:nodeId`. A public reader (or `?approved_only=1`) always gets the **approved** version's bytes — never a draft's — whatever version is asked for; other readers get the current working copy, or `?version=N`. Every response is verified against the sha256 its version records (a mismatch is refused, not served).
815
+
816
+ Headers: `Content-Type: application/pdf`, `Content-Disposition: inline; filename="<slug>.pdf"` (`?download=1` → `attachment`), `Content-Security-Policy: sandbox`, `X-Content-Type-Options: nosniff`, `Cache-Control: private, no-cache`, and an `ETag` of the sha256 (`If-None-Match` → `304`). `404` when the node has no such version or no approved version (public). An id that is not a pdf node falls through to the node route, so a document whose own id ends in `/pdf` still reads.
817
+
818
+ ### POST `/nests/:nestId/assets`
819
+ Multipart upload (field `file`) of an image or video referenced from documents. Write permission.
820
+
821
+ Supported types and per-type size caps:
822
+
823
+ | Type | Extensions | Max size |
824
+ |------|-----------|----------|
825
+ | Image | `png`, `jpg`/`jpeg`, `gif`, `webp` | 10 MB |
826
+ | Video | `mp4`, `webm` | 100 MB |
827
+
828
+ 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).
829
+
830
+ ### GET `/nests/:nestId/assets/:file`
831
+ Serves the asset bytes with the correct content-type and immutable caching. Read permission. Only server-minted `<uuid>.<ext>` names are accepted.
832
+
833
+ ### Watchers — GET/POST/DELETE `/nests/:nestId/nodes/:nodeId/watchers`
834
+ "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.
835
+
836
+ ---
837
+
838
+ ## 5. Stewardship
839
+
840
+ ### GET `/nests/:nestId/stewards`
841
+ ```json
842
+ { "stewards": [{
843
+ "id": "...",
844
+ "userEmail": "rev@x.com",
845
+ "userId": "...",
846
+ "scope": "document" | "tag" | "nest",
847
+ "nodePattern": "nodes/foo" | "<nestId>" | null,
848
+ "tagName": "api" | null,
849
+ "role": "editor" | "reviewer" | "viewer"
850
+ }] }
851
+ ```
852
+
853
+ ### POST `/nests/:nestId/stewards`
854
+ **Two shapes accepted.**
855
+
856
+ **Legacy single-user (what the UI sends):**
857
+ ```json
858
+ {
859
+ "scope": "document",
860
+ "email": "rev@x.com",
861
+ "role": "reviewer",
862
+ "nodePattern": "nodes/foo"
863
+ }
864
+ ```
865
+
866
+ Field selection by scope:
867
+ | scope | required field | meaning |
868
+ |---|---|---|
869
+ | `document` | `nodePattern` | exact node id |
870
+ | `tag` | `tagName` | bare tag, no `#` |
871
+ | `nest` | — | covers every node in the nest |
872
+
873
+ **Multi-user shape:**
874
+ ```json
875
+ {
876
+ "scope": "tag",
877
+ "tagName": "api",
878
+ "users": [
879
+ { "email": "r1@x.com", "role": "reviewer" },
880
+ { "email": "r2@x.com", "role": "editor" }
881
+ ]
882
+ }
883
+ ```
884
+ For document scope use `documentId`; for nest no target. Sending `scope: "folder"` returns **400** — folder scope was removed.
885
+
886
+ **Side effects on POST:**
887
+ 1. Auto-flips `nests.stewardship_enabled = 1` on the nest.
888
+ 2. Auto-creates `users` row with `is_invited = 1` if email unknown.
889
+ 3. Auto-inserts `nest_collaborators`: `editor → write`, `reviewer | viewer → read`.
890
+ 4. Idempotent — duplicate `(nest, scope, target, email)` returns the existing row.
891
+
892
+ Response (201): `{ "stewards": [...] }` (multi) or `{ "steward": {...} }` (legacy single).
893
+
894
+ ### DELETE `/nests/:nestId/stewards/:stewardId`
895
+ Soft-removes (`is_active = 0`).
896
+
897
+ ### POST `/nests/:nestId/stewards/sync`
898
+ Re-imports stewards from `<nest>/.context/stewards.yaml` if present.
899
+ Response: `{ "synced": N }`.
900
+
901
+ ### GET `/nests/:nestId/nodes/:nodeId/stewards`
902
+ Resolved stewards for one node (priority `document(1) > tag(2) > nest(3)`).
903
+ ```json
904
+ {
905
+ "nodeId": "nodes/foo",
906
+ "stewards": [{
907
+ "email": "rev@x.com",
908
+ "role": "reviewer",
909
+ "scope": "document",
910
+ "source": "document: nodes/foo",
911
+ "priority": 1
912
+ }],
913
+ "fallbackToOwner": false,
914
+ "ownerEmail": null
915
+ }
916
+ ```
917
+
918
+ ### GET `/nests/:nestId/nodes/:nodeId/can-access`
919
+ `{ "allowed": true, "reason": "...", "role": "reviewer" | null }`
920
+
921
+ ### GET `/nests/:nestId/nodes/:nodeId/can-approve`
922
+ Same shape. Allowed iff the caller has a matched steward row with `role: reviewer` covering this node.
923
+
924
+ ### GET `/nests/:nestId/nodes/:nodeId/can-edit`
925
+ Same shape. Reflects nest collaborator permission OR steward `editor` role.
926
+
927
+ ---
928
+
929
+ ## 6. Review workflow
930
+
931
+ ### POST `/nests/:nestId/nodes/:nodeId/submit-review`
932
+ ```json
933
+ { "note": "ready for review", "priority": "low" | "normal" | "high" | "urgent" }
934
+ ```
935
+ Sets node status → `pending_review`. UI locks editing.
936
+
937
+ ### POST `/nests/:nestId/nodes/:nodeId/approve`
938
+ ```json
939
+ { "note": "lgtm", "override": false }
940
+ ```
941
+ - Server checks caller has a steward row covering this node with `role = 'reviewer'`. **403 `Insufficient permissions`** otherwise.
942
+ - Nest middleware allows `read`-perm collaborators through this path; per-row stewardship enforces real authorization.
943
+ - `override: true` only honored for the super-admin license owner.
944
+
945
+ Response: `{ "review": { "id": "...", "status": "approved", ... } }`.
946
+
947
+ ### POST `/nests/:nestId/nodes/:nodeId/reject`
948
+ ```json
949
+ { "note": "needs change X" }
950
+ ```
951
+ `note` required. Same permission gating as approve (`role = 'reviewer'`).
952
+
953
+ ### POST `/nests/:nestId/nodes/:nodeId/cancel-review`
954
+ Cancels the pending review. Returns node to `draft`.
955
+
956
+ Allowed for the person who submitted it (withdrawing your own submission) and
957
+ for anyone with approve rights on the node. Anyone else gets 403 — read access
958
+ to the nest alone is not enough.
959
+
960
+ ### GET `/nests/:nestId/external-edits`
961
+ Query: `?refresh=true` to rescan the disk first; `?count=1` to get the total only.
962
+
963
+ 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.
964
+ ```json
965
+ { "entries": [{ "suggestion_id": "...", "document_id": "nodes/foo", "source": "out-of-band-edit", "detected_at": "...", "actor": "..." }], "total": 1 }
966
+ ```
967
+
968
+ ### GET `/nests/:nestId/review-queue`
969
+ Query: `?status=pending&limit=50&offset=0`
970
+
971
+ Returns ALL reviews for the nest (server does not gate by reviewer scope).
972
+ ```json
973
+ {
974
+ "requests": [{
975
+ "id": "...",
976
+ "nodeId": "nodes/foo",
977
+ "version": 3,
978
+ "requestedBy": "author@x.com",
979
+ "requestedAt": "...",
980
+ "status": "pending",
981
+ "priority": "normal",
982
+ "requestNote": "..."
983
+ }],
984
+ "total": 1
985
+ }
986
+ ```
987
+
988
+ **UI filtering** (recent change) — the React layer hides:
989
+ - Items the caller submitted (separation of duties).
990
+ - Items outside the caller's steward scope. **No admin/owner bypass.**
991
+
992
+ So Postman will see more rows than the UI; that's expected.
993
+
994
+ ### POST `/nests/:nestId/review-queue/bulk`
995
+ Body: `{ "nodeIds": ["nodes/a", "nodes/b"], "action": "approve" | "reject", "note": "..." }`
996
+
997
+ One decision applied to several pending documents. Each item runs through the
998
+ same `approve` / `reject` as the per-document routes, so permission resolution,
999
+ state validation, history and notifications are identical — this is a wrapper,
1000
+ not a second governance path. A rejection still requires `note`, and there is no
1001
+ override: forcing past a refusal stays a per-document decision.
1002
+
1003
+ Partial success is the normal outcome, so the call returns `200` with a per-item
1004
+ report rather than failing the batch:
1005
+
1006
+ ```json
1007
+ {
1008
+ "action": "approve",
1009
+ "succeeded": 2,
1010
+ "failed": 1,
1011
+ "results": [
1012
+ { "nodeId": "nodes/a", "ok": true },
1013
+ { "nodeId": "nodes/b", "ok": true },
1014
+ { "nodeId": "nodes/c", "ok": false, "error": "Approvals go to assigned reviewers…" }
1015
+ ]
1016
+ }
1017
+ ```
1018
+
1019
+ Duplicate ids collapse. Up to **50** ids per call; an empty or oversized
1020
+ selection is refused up front (`400`) rather than silently truncated.
1021
+
1022
+ ### GET `/nests/:nestId/nodes/:nodeId/reviews`
1023
+ Full review history for a node.
1024
+
1025
+ ### POST `/nests/:nestId/nodes/:nodeId/request-deletion`
1026
+ Body: `{ "reason": "superseded by the new runbook" }` (required).
1027
+
1028
+ **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.
1029
+
1030
+ 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 }`.
1031
+
1032
+ `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.
1033
+
1034
+ ### POST `/nests/:nestId/request-deletion`
1035
+ Body: `{ "targetType": "folder" | "nest" | "nest_archive", "target": "api/limits", "reason": "..." }` (`target` ignored for both nest targets).
1036
+
1037
+ `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.
1038
+
1039
+ 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).
1040
+
1041
+ ### POST `/nests/:nestId/delete-folder`
1042
+ 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.
1043
+
1044
+ Folders are not stored objects — they exist only as segments of the node ids beneath them, so deleting a folder *is* deleting its documents.
1045
+
1046
+ ### GET `/nests/:nestId/deletion-requests`
1047
+ 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.
1048
+
1049
+ ```json
1050
+ {
1051
+ "requests": [{
1052
+ "id": "...",
1053
+ "nodeId": "nodes/foo",
1054
+ "title": "Foo",
1055
+ "requestedBy": "reader@x.com",
1056
+ "reason": "superseded",
1057
+ "status": "pending",
1058
+ "createdAt": "..."
1059
+ }],
1060
+ "total": 1
1061
+ }
1062
+ ```
1063
+
1064
+ ### POST `/nests/:nestId/deletion-requests/:id/delete`
1065
+ 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).
1066
+
1067
+ 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`.
1068
+
1069
+ 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.
1070
+
1071
+ 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.
1072
+
1073
+ `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.
1074
+
1075
+ ### POST `/nests/:nestId/deletion-requests/:id/decline`
1076
+ 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.
1077
+
1078
+ ### GET `/nests/:nestId/feed`
1079
+ Query: `?filter=all|changes|comments&limit=50&offset=0` (filter defaults to `all`, limit caps at 200, offset caps at 10 000)
1080
+
1081
+ Nest **activity feed** — pending change submissions (the exact rows
1082
+ `/review-queue` shows, every field intact, plus the caller's `canReview`)
1083
+ interleaved with annotation activity (new comment threads AND replies),
1084
+ newest first. Additive sibling of `/review-queue`; that endpoint and the MCP
1085
+ `context_review_queue` tool are unchanged.
1086
+
1087
+ Comment items are access-filtered to documents the caller can read (same
1088
+ resolution as node listing: public readers see approved docs only; grants and
1089
+ steward scope honored). Change items mirror the queue (not access-filtered).
1090
+
1091
+ ```json
1092
+ {
1093
+ "items": [
1094
+ {
1095
+ "type": "comment",
1096
+ "id": "<commentId>",
1097
+ "threadId": "<threadId>",
1098
+ "nodeId": "nodes/foo",
1099
+ "title": "Foo",
1100
+ "author": "reviewer@x.com",
1101
+ "snippet": "First ~200 chars of the comment…",
1102
+ "createdAt": "2026-07-14 11:00:00",
1103
+ "resolved": false,
1104
+ "isReply": true
1105
+ },
1106
+ {
1107
+ "type": "change",
1108
+ "id": "<reviewRequestId>",
1109
+ "nestId": "...",
1110
+ "nodeId": "nodes/foo",
1111
+ "title": "Foo",
1112
+ "version": 3,
1113
+ "requestedBy": "author@x.com",
1114
+ "requestedAt": "2026-07-14 10:00:00",
1115
+ "requestNote": "...",
1116
+ "status": "pending",
1117
+ "priority": "normal",
1118
+ "canReview": true,
1119
+ "createdAt": "2026-07-14 10:00:00"
1120
+ }
1121
+ ],
1122
+ "total": 2,
1123
+ "counts": { "changes": 1, "comments": 1 },
1124
+ "filter": "all",
1125
+ "limit": 50,
1126
+ "offset": 0
1127
+ }
1128
+ ```
1129
+
1130
+ `counts` always carries both per-type totals regardless of `filter`; the UI
1131
+ tab badge binds to `counts.changes` (actionable items), not the feed length.
1132
+ The UI deep-links a comment item to its document with the thread focused via
1133
+ `?nest=<id>&doc=<nodeId>&thread=<threadId>`.
1134
+
1135
+ ### Notification rows (`GET /me/work` → `notifications`, `GET /me/notifications`)
1136
+
1137
+ 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`.
1138
+
1139
+ ### GET `/me/drafts`
1140
+
1141
+ Query: `?limit=25&offset=0&q=&sort=updated|title` (limit clamps to 1–100, offset to ≥ 0)
1142
+
1143
+ `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.
1144
+
1145
+ Every document **you** left unsubmitted, across every nest you can see. A draft
1146
+ that was never submitted has no `review_requests` row, so it appears in no
1147
+ review queue and no `/me/work` bucket — this is the only surface that finds it
1148
+ again. Backs the dashboard's "My Drafts" tab.
1149
+
1150
+ Included: nodes whose **latest** version row is authored by the caller with
1151
+ status `draft` or `rejected`, and that have no pending review request (those
1152
+ are already in `/me/work` under `waiting`). Approved/published documents are
1153
+ deliberately excluded — they are not stranded work. Rows for deleted documents
1154
+ are filtered out.
1155
+
1156
+ ```json
1157
+ {
1158
+ "drafts": [
1159
+ {
1160
+ "nest_id": "<nestId>",
1161
+ "nest_name": "GTM",
1162
+ "node_id": "nodes/gtm/cohere",
1163
+ "title": "Cohere Kill Sheet",
1164
+ "folder": "gtm",
1165
+ "status": "draft",
1166
+ "version": 3,
1167
+ "updated_at": "2026-07-23 09:12:00"
1168
+ }
1169
+ ],
1170
+ "pagination": { "total": 137, "limit": 25, "offset": 0, "has_more": true }
1171
+ }
1172
+ ```
1173
+
1174
+ `total` is counted in SQL, before the existence filter that drops orphaned
1175
+ version rows (a document deleted outside the app), so it can run one high and
1176
+ leave a page one row short. `has_more` is derived from the **requested** page
1177
+ size for that reason, not from the number of rows returned.
1178
+
1179
+ Submitting one is the ordinary `POST /nests/:nestId/nodes/:nodeId/submit-review`
1180
+ — it then leaves this list and appears in `/me/work`.
1181
+
1182
+ ---
1183
+
1184
+ ## 7. Versions
1185
+
1186
+ ### GET `/nests/:nestId/nodes/:nodeId/versions`
1187
+
1188
+ Version metadata plus each version's **change log** — the unified diff taking the
1189
+ previous version to this one, as the engine stored it. `diff` is absent for v1
1190
+ and for keyframe versions (a keyframe is a full snapshot, so there is no patch).
1191
+
1192
+ `content` is always `""` here. The list deliberately does **not** carry bodies:
1193
+ attaching them means replaying the version chain once per row and shipping N
1194
+ full copies of the document just to open the history panel. Fetch the one body
1195
+ you need from `GET .../versions/:version` below.
1196
+
1197
+ Each version may also include `resolvedBy` + `resolutionStatus`, the steward who
1198
+ approved or rejected it, joined from `review_requests` (`node_versions` stores
1199
+ the status but not the resolver's email).
1200
+
1201
+ `client` is the caller attribution the write carried — which agent, in which
1202
+ session (see [Caller attribution](#caller-attribution-client)). Absent on every
1203
+ version whose write sent none, which includes every version written before the
1204
+ field existed.
1205
+
1206
+ `externalEditVerdicts` carries the verdicts on out-of-band (direct filesystem)
1207
+ edits, oldest first, read from the engine's append-only chain-event log. They sit
1208
+ beside the version list rather than inside it because a **rejection commits no
1209
+ content and therefore has no version** — minting one would renumber the chain.
1210
+ An approval does commit, so it appears in both: as the version it created, and as
1211
+ a verdict here. `note` is the rejection reason, or the approver's comment when
1212
+ one was left.
1213
+
1214
+ Gated by the same node-level read check as the document itself — `403` when the
1215
+ caller can't read the node, which for a public reader means any node without an
1216
+ approved version.
1217
+
1218
+ ```json
1219
+ {
1220
+ "currentVersion": 3,
1221
+ "approvedVersion": 2,
1222
+ "versions": [
1223
+ {
1224
+ "version": 3,
1225
+ "editedBy": "author@x.com",
1226
+ "editedAt": "...",
1227
+ "status": "pending_review",
1228
+ "changeNote": "...",
1229
+ "content": "",
1230
+ "client": { "agent": "claude-code", "session_id": "s-9f2" },
1231
+ "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"
1232
+ },
1233
+ {
1234
+ "version": 2,
1235
+ "editedBy": "author@x.com",
1236
+ "editedAt": "...",
1237
+ "status": "approved",
1238
+ "changeNote": "...",
1239
+ "content": "",
1240
+ "resolvedBy": "reviewer@x.com",
1241
+ "resolutionStatus": "approved"
1242
+ }
1243
+ ],
1244
+ "externalEditVerdicts": [
1245
+ {
1246
+ "suggestion_id": "20240115T103000-a1b2c3d4",
1247
+ "status": "rejected",
1248
+ "actor": "reviewer@x.com",
1249
+ "at": "2024-01-15T10:34:00.000Z",
1250
+ "note": "not sourced",
1251
+ "source": "out-of-band-edit"
1252
+ }
1253
+ ]
1254
+ }
1255
+ ```
1256
+
1257
+ ### GET `/nests/:nestId/nodes/:nodeId/versions/:version`
1258
+
1259
+ One version's full body, reconstructed on demand from the nearest keyframe plus
1260
+ the diffs after it.
1261
+
1262
+ `content` is `null` — with HTTP 200, not an error — when that version's chain can
1263
+ no longer be replayed (e.g. history grafted by an older import). Callers render
1264
+ it as "no content" rather than failing the whole panel.
1265
+
1266
+ Same node-level read check as the list above: `403` when the caller can't read
1267
+ the node. A public reader can only reach versions of a node that has an approved
1268
+ version, so drafts of an unapproved document are never reconstructable this way.
1269
+
1270
+ ```json
1271
+ { "version": 2, "content": "# Heading\n\nbody as of v2\n" }
1272
+ ```
1273
+
1274
+ ---
1275
+
1276
+ ## 8. Comments & activity
1277
+
1278
+ Comments are a standalone collaboration primitive — **not** part of the
1279
+ version/hash chain. They thread, carry attribution, anchor to a highlighted
1280
+ span, and resolve (Word/Docs-style "Comments", separate from Track Changes).
1281
+ Any caller with **read** access to the nest may leave or resolve a comment.
1282
+
1283
+ ### GET `/nests/:nestId/nodes/:nodeId/comments?status=open`
1284
+ List comments on a node. Optional `status` = `open` | `resolved`.
1285
+ ```json
1286
+ {
1287
+ "comments": [
1288
+ {
1289
+ "id": "…",
1290
+ "nestId": "…",
1291
+ "nodeId": "nodes/spec",
1292
+ "version": 1,
1293
+ "anchor": { "start": 8, "end": 19, "text": "first draft" },
1294
+ "parentId": null,
1295
+ "author": "owner@x.com",
1296
+ "body": "This needs a citation.",
1297
+ "status": "open",
1298
+ "createdAt": "…"
1299
+ }
1300
+ ]
1301
+ }
1302
+ ```
1303
+
1304
+ ### POST `/nests/:nestId/nodes/:nodeId/comments`
1305
+ Leave a comment. Body: `{ body, version?, anchor?{start,end,text?}, parentId? }`.
1306
+ `parentId` threads a reply. Returns `201 { comment }`. Empty body → `400`.
1307
+
1308
+ **`@mentions` notify.** Every name in the comment body — `@user@host`,
1309
+ `@localpart`, or `@(Display Name)` — is resolved against the nest's people
1310
+ (`GET /nests/:nestId/mentionable`) and each person named gets a `mention` row
1311
+ in `GET /me/notifications` linking the document, plus a message on the
1312
+ configured connector and an email to their own address when SMTP is set up.
1313
+ A channel message names who was mentioned (a room is not a person); the inbox
1314
+ row and the email address the recipient directly. The author is never notified
1315
+ about their own mention, a name matching nobody
1316
+ (or matching two people ambiguously) notifies nobody, and a recipient who
1317
+ can't open the document is skipped — so a mention can't disclose a title.
1318
+ Unlike a document body, where only names a save *adds* notify, every name in a
1319
+ comment notifies: the comment itself is the new event. At most 25 people are
1320
+ notified per write (`MAX_MENTION_RECIPIENTS`) — commenting is read-tier and
1321
+ unthrottled, so a comment naming a large roster would otherwise be an
1322
+ amplifier; names are taken in order of appearance. With `PEOPLE_SUGGEST_ALL_USERS`
1323
+ on, a name that matches a registered account off the nest is an invite: a
1324
+ write+ author's mention adds them as a `read` collaborator and then notifies;
1325
+ a read-only author's mention of an outsider still reaches nobody.
1326
+
1327
+ The *stream* of them is bounded too: per nest per 5 minutes, one person may
1328
+ cause 50 mention **emails** (`MENTION_DELIVERY_BUDGET`) and 25 mention
1329
+ **channel posts** (`MENTION_CHANNEL_BUDGET`) — separate pools, because emails
1330
+ scale with how many people were named while a channel post is one message per
1331
+ comment. Separate also so the email allowance doesn't depend on something
1332
+ invisible: the connector dispatch is a no-op on a nest with no connector
1333
+ configured, and a shared pool would have spent the token before it could say
1334
+ so, quietly halving the email budget on exactly the deployments least likely to
1335
+ have a connector. Past that the comment still posts and the
1336
+ inbox rows still land — the queue is the recipient's own record and costs one
1337
+ insert — and only the external fan-out is skipped, with a server-log warning.
1338
+ The budget sits on delivery rather than on comment creation deliberately: an
1339
+ agent working through a review posts a comment per finding, which is exactly
1340
+ what `context_comment` is for, and throttling the write would break it. The same applies to the
1341
+ annotation thread endpoints (§ Hosted artifacts), which are what the UI's
1342
+ comments panel posts.
1343
+
1344
+ ### POST `/nests/:nestId/nodes/:nodeId/comments/:commentId/resolve`
1345
+ Mark a comment resolved. Records `resolvedBy` + `resolvedAt`. Returns `{ comment }`.
1346
+
1347
+ ### GET `/nests/:nestId/nodes/:nodeId/activity` · GET `/nests/:nestId/activity`
1348
+ Per-node (or nest-wide) activity log — "who did what". A read-only aggregation
1349
+ over comments + committed edits (`node_versions`) + pending external-edit
1350
+ suggestions ("proposed an edit", read from nest storage) + reviews
1351
+ (`review_requests`); newest first, `?limit=` (default 100). Each entry:
1352
+ `{ type, nodeId, actor, at, detail?, refId? }` where `type` ∈
1353
+ `comment | comment_resolved | edit | edit_proposed | review_requested | review_resolved`.
1354
+
1355
+
1356
+ ### Annotations — anchored threads the editor's Comment mode shows
1357
+
1358
+ 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.
1359
+
1360
+ - `GET /nests/:nestId/nodes/:nodeId/annotations` — list threads with their replies and `open` / `resolved` status.
1361
+ - `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.
1362
+ - `POST /nests/:nestId/nodes/:nodeId/annotations/:threadId/comments` — reply. Body: `{ body }`.
1363
+ - `POST /nests/:nestId/nodes/:nodeId/annotations/:threadId/resolve` · `/reopen`.
1364
+ - `GET /nests/:nestId/comment-counts` — `{ "<nodeId>": <open thread count>, … }` for the whole nest in one call (the UI's "most commented" sort).
1365
+
1366
+ ---
1367
+
1368
+ ## 8b. Glossary definitions
1369
+
1370
+ A literal table-based ontology per nest: stewards/editors define and articulate
1371
+ terms — existing `#tags` or brand-new vocabulary — and external runtimes (quant
1372
+ analysis jobs, agents) pull them in real time. One row per `(nest, term)`,
1373
+ case-insensitive. Reads are read-tier; writes are write-tier.
1374
+
1375
+ ### GET `/nests/:nestId/definitions`
1376
+ List the whole glossary, ordered by term.
1377
+ ```json
1378
+ {
1379
+ "count": 1,
1380
+ "definitions": [{
1381
+ "id": "...",
1382
+ "nest_id": "...",
1383
+ "term": "CAC",
1384
+ "definition": "Customer acquisition cost: S&M spend / new customers, 90d.",
1385
+ "linked_tag": "#gtm",
1386
+ "defined_by": "owner@acme.com",
1387
+ "created_at": "...",
1388
+ "updated_at": "..."
1389
+ }]
1390
+ }
1391
+ ```
1392
+
1393
+ ### GET `/nests/:nestId/definitions?term=CAC`
1394
+ Real-time single lookup — one indexed, case-insensitive query. `404` if the term
1395
+ isn't defined.
1396
+ ```json
1397
+ { "definition": { "id": "...", "term": "CAC", "definition": "...", "linked_tag": "#gtm", "defined_by": "...", "created_at": "...", "updated_at": "..." } }
1398
+ ```
1399
+
1400
+ ### POST `/nests/:nestId/definitions` (write+)
1401
+ Upsert by term text (case-insensitive). New term → `201`; re-defining an existing
1402
+ term updates it in place → `200`.
1403
+ ```json
1404
+ { "term": "CAC", "definition": "...", "linked_tag": "gtm", "editing_id": "<id?>" }
1405
+ ```
1406
+ - `term` required, ≤ 200 chars. `definition` required. Non-string `term`/`definition`/`linked_tag` → `400`.
1407
+ - `linked_tag`: omit = leave unchanged on update; `null`/`""` = clear; string = set (normalized to `#lowercase`).
1408
+ - `editing_id` (optional): the id of the row being edited. Scopes the upsert to
1409
+ that row so a **rename replaces it in place** instead of overwriting whichever
1410
+ row happens to own the new term. If the new term already belongs to a
1411
+ **different** row → `409` (no data loss). Unknown `editing_id` → `404`. Omit it
1412
+ for the plain add form and real-time writers (pure upsert-by-term).
1413
+
1414
+ ### DELETE `/nests/:nestId/definitions/:id` (write+)
1415
+ ```json
1416
+ { "deleted": true }
1417
+ ```
1418
+ `404` if the id isn't in this nest.
1419
+
1420
+ ---
1421
+
1422
+ ## 8c. Edge-type registry ("turing" workflow plane)
1423
+
1424
+ The vocabulary of connections: the definitions-table move applied to relations.
1425
+ Stewards articulate once what `next` or `escalates-when` MEANS in a nest; edges
1426
+ then reference the registry. **Feature-flagged** — every route here `404`s
1427
+ unless `FEATURE_WORKFLOW_PLANE` is on (env var or the admin-settings toggle).
1428
+ Reads are read-tier; writes are write-tier. One row per `(nest, name)`,
1429
+ case-insensitive.
1430
+
1431
+ On first touch a nest is lazily seeded with five stock types (attributed to the
1432
+ nest owner): `next`, `on-success`, `on-failure` (flow types, form the DAG),
1433
+ `depends-on`, `owned-by`.
1434
+
1435
+ ### GET `/nests/:nestId/edge-types`
1436
+ List the registry, ordered by name. Seeds the defaults if empty.
1437
+ ```json
1438
+ {
1439
+ "count": 5,
1440
+ "edge_types": [{
1441
+ "id": "...",
1442
+ "nest_id": "...",
1443
+ "name": "next",
1444
+ "description": "Unconditional flow: after the source completes, run the target.",
1445
+ "direction": "directed",
1446
+ "is_flow": true,
1447
+ "condition_schema": null,
1448
+ "color": "#16a34a",
1449
+ "created_by": "owner@acme.com",
1450
+ "created_at": "...",
1451
+ "updated_at": "..."
1452
+ }]
1453
+ }
1454
+ ```
1455
+
1456
+ ### POST `/nests/:nestId/edge-types` (write+)
1457
+ Upsert by name (case-insensitive). New name → `201`; re-defining an existing
1458
+ name updates it in place → `200`.
1459
+ ```json
1460
+ {
1461
+ "name": "escalates-when",
1462
+ "description": "Conditional escalation on a metric guardrail.",
1463
+ "direction": "directed",
1464
+ "is_flow": true,
1465
+ "color": "#f59e0b",
1466
+ "condition_schema": { "params": ["term", "op", "value"], "mode_default": "structured" }
1467
+ }
1468
+ ```
1469
+ - `name` required, ≤ 100 chars, `^[a-z0-9][a-z0-9-]*$` (alphanumeric-with-dashes). `description` required.
1470
+ - `direction`: `directed` (default) | `undirected`. Anything else → `400`.
1471
+ - `is_flow`: boolean, default `false`. Flow types form the workflow DAG.
1472
+ - `color`: optional 6-digit hex (e.g. `#16a34a`). A malformed color → `400` (not silently dropped).
1473
+ - `condition_schema`: optional `{ "params": [string…], "mode_default"?: "structured"|"nl"|"open" }`. Malformed → `400`. Stored normalized (`mode_default` defaults to `structured`).
1474
+
1475
+ ### DELETE `/nests/:nestId/edge-types/:id` (write+)
1476
+ ```json
1477
+ { "deleted": true }
1478
+ ```
1479
+ `404` if the id isn't in this nest. `400` if edges still reference the type
1480
+ (delete those edges first — the DB enforces this with `ON DELETE RESTRICT`).
1481
+
1482
+ ---
1483
+
1484
+ ## 9. Query / Search (read-only consumers)
1485
+
1486
+ These power LLM context retrieval. Open-mode friendly.
1487
+
1488
+ ### GET `/nests/:nestId/search?q=<term>`
1489
+ Full-text search.
1490
+ ```json
1491
+ { "count": N, "nodes": [{ "id": "...", "title": "...", "tags": [...] }] }
1492
+ ```
1493
+
1494
+ ### GET `/search?q=<term>&limit=20`
1495
+ The same match, run across every nest the caller can see — for finding a
1496
+ document without knowing which nest holds it. Each hit is stamped with its
1497
+ nest, and per-nest stewardship filtering still applies, so results never
1498
+ include a document the caller couldn't open directly.
1499
+
1500
+ `limit` caps the returned page (default 20, max 50) while `count` reports
1501
+ everything found. `400` if `q` is missing.
1502
+ ```json
1503
+ {
1504
+ "query": "onboarding",
1505
+ "count": 2,
1506
+ "nodes": [
1507
+ {
1508
+ "id": "nodes/onboarding-runbook",
1509
+ "title": "Onboarding Runbook",
1510
+ "type": "document",
1511
+ "tags": ["#hr"],
1512
+ "snippet": "how to onboard a...",
1513
+ "nest_id": "nest_abc",
1514
+ "nest_name": "Team Knowledge"
1515
+ }
1516
+ ]
1517
+ }
1518
+ ```
1519
+
1520
+ ### POST `/nests/:nestId/context`
1521
+ One-call context retrieval for app/agent flows. Returns markdown ready to
1522
+ prepend to an LLM prompt, plus a trace + node manifest. **Requires a license**
1523
+ (it's a `POST`).
1524
+
1525
+ Body (give `prompt` OR `selector`; everything else optional):
1526
+ ```json
1527
+ {
1528
+ "prompt": "how does auth work",
1529
+ "selector": "#auth",
1530
+ "max_tokens": 4000,
1531
+ "hops": 2,
1532
+ "include_drafts": false
1533
+ }
1534
+ ```
1535
+ - `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.
1536
+ - `selector` — explicit selector grammar (e.g. `#auth`). Wins over `prompt`.
1537
+ - `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`.
1538
+ - `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.
1539
+ - 400 `prompt or selector is required` if both missing.
1540
+ - 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.
1541
+ - 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.
1542
+
1543
+ Response (200):
1544
+ ```json
1545
+ {
1546
+ "context": "# Auth Overview\n\n_tags: #auth #security_\n_source: http://localhost:3838/?nest=<nestId>&doc=nodes/auth-overview_\n\n# Auth\n\n...",
1547
+ "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" }],
1548
+ "trace": {
1549
+ "selector_used": "#auth",
1550
+ "from_prompt": "how does auth work",
1551
+ "compiled": { "matched_tags": ["auth"], "matched_titles": [], "unmatched_tokens": ["work"], "fallback": null },
1552
+ "included": 1,
1553
+ "permission_filtered": 0,
1554
+ "truncated_by_budget": 0,
1555
+ "approx_tokens": 24,
1556
+ "max_tokens": 4000
1557
+ }
1558
+ }
1559
+ ```
1560
+
1561
+ ```bash
1562
+ curl -X POST 'http://localhost:3838/nests/<nestId>/context' \
1563
+ -H 'Content-Type: application/json' \
1564
+ -H 'Authorization: Bearer cnst_...' \
1565
+ -d '{"prompt":"how does auth work","max_tokens":4000}'
1566
+ ```
1567
+ Permission gating matches `/export` (public readers only see approved bodies).
1568
+
1569
+ ### GET `/nests/:nestId/export?format=markdown` — LLM-pointed plain-text view
1570
+ Stable URL an agent can fetch to get **`text/markdown`** (not JSON): every
1571
+ accessible node serialized as YAML frontmatter + body, joined with `---`
1572
+ separators. A `GET`, so it works on public nests without a license/credentials.
1573
+
1574
+ **Approved-only, for every caller.** Export never emits unapproved drafts —
1575
+ not even for the owner. On a governed (stewardship-on) nest, each node's body
1576
+ is its **approved version**, and nodes with no approved version yet are
1577
+ omitted entirely. On an ungoverned nest there's no draft concept, so the
1578
+ current body is exported.
1579
+
1580
+ Query params:
1581
+ - `format=markdown` — **required** (else 400 `format=markdown is required`).
1582
+ - `selector=<grammar>` — optional, narrows the set (e.g. `#auth`).
1583
+ - `max_tokens=<n>` — optional budget; full set by default.
1584
+
1585
+ Response (`text/markdown; charset=utf-8`):
1586
+ ```markdown
1587
+ ---
1588
+ title: "Auth Overview"
1589
+ tags: ["#auth", "#security"]
1590
+ status: "published"
1591
+ id: "nodes/auth-overview"
1592
+ ---
1593
+ # Auth
1594
+
1595
+ We use device auth + email/password fallback.
1596
+
1597
+ ---
1598
+
1599
+ ---
1600
+ title: "Rate Limits"
1601
+ tags: ["#api"]
1602
+ status: "published"
1603
+ id: "nodes/rate-limits"
1604
+ ---
1605
+ # Limits
1606
+
1607
+ 100 req/min per key.
1608
+ ```
1609
+
1610
+ ```bash
1611
+ # whole nest
1612
+ curl 'http://localhost:3838/nests/<nestId>/export?format=markdown'
1613
+
1614
+ # narrow by selector
1615
+ curl 'http://localhost:3838/nests/<nestId>/export?format=markdown&selector=%23auth'
1616
+
1617
+ # cap output size
1618
+ curl 'http://localhost:3838/nests/<nestId>/export?format=markdown&max_tokens=2000'
1619
+ ```
1620
+
1621
+ Single node, same shape — add `?format=markdown` to the node read:
1622
+ ```bash
1623
+ curl 'http://localhost:3838/nests/<nestId>/nodes/nodes/auth-overview?format=markdown'
1624
+ ```
1625
+ Public readers receive the **approved** version body, never an unapproved
1626
+ working-copy draft (same gate as `/context`).
1627
+
1628
+ ### GET `/nests/:nestId/export?format=bundle` — portability bundle
1629
+
1630
+ 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`).
1631
+
1632
+ - `?includeDrafts=1` — also export unapproved draft documents. Default: approved/published only.
1633
+
1634
+ Identities/attribution and the governance overlay (stewards, reviews, comments, definitions, edges, grants) do **not** travel; the importer owns and re-governs the fresh nest.
1635
+
1636
+ ```bash
1637
+ curl -OJ 'http://localhost:3838/nests/<nestId>/export?format=bundle'
1638
+ curl -OJ 'http://localhost:3838/nests/<nestId>/export?format=bundle&includeDrafts=1'
1639
+ ```
1640
+
1641
+ ### POST `/nests/:nestId/table-query`
1642
+ 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`.
1643
+ ```json
1644
+ { "table": "[[Price List]]", "query": "where region = \"EU\" and price > 100 select sku, price limit 5" }
1645
+ ```
1646
+ `table` is a `[[Title]]` or a node id (`nodes/price-list`). Grammar: `where <col> <op> <value> [and …] [select <cols>] [limit N]`. Response:
1647
+ ```json
1648
+ { "table": "nodes/price-list", "version": 3, "columns": ["sku", "price"], "rows": [["A-1", "120"]], "matched": 7, "returned": 5, "total": 1000 }
1649
+ ```
1650
+ Same operation over MCP: `context_table_query`.
1651
+
1652
+ ### POST `/nests/:nestId/query`
1653
+ Graph query (Cypher-ish).
1654
+
1655
+ ### GET `/nests/:nestId/overview`
1656
+ Nest-level summary stats.
1657
+
1658
+ ### GET `/nests/:nestId/context`
1659
+ Returns CONTEXT.md content.
1660
+
1661
+ ### GET `/nests/:nestId/folders`
1662
+ The nest's folder tree on its own, for callers that want the *shape* of the vault
1663
+ (a navigable tree, a folder picker) and none of its content. Per-folder counts come
1664
+ from the document index and the folders themselves from the vault's directories —
1665
+ no document is opened either way, so this stays cheap on a vault where listing the
1666
+ documents would not be. A folder holding no document is listed with `count: 0`.
1667
+
1668
+ Query: `?folder=<path>` (that subtree only; defaults to the whole nest),
1669
+ `?recursive=0` (direct children of `folder` only).
1670
+
1671
+ ```json
1672
+ { "folders": [{ "path": "specs", "count": 4, "total": 11 }] }
1673
+ ```
1674
+
1675
+ `count` = documents filed directly in that folder; `total` = the whole subtree
1676
+ beneath it. A level that holds only subfolders still gets a row, so it stays
1677
+ expandable.
1678
+
1679
+ ### POST `/nests/:nestId/folders`
1680
+ ```json
1681
+ { "folder": "gtm/deals" }
1682
+ ```
1683
+ Creates a folder with nothing in it — the directory is created in the vault
1684
+ carrying only its generated `INDEX.md`, so no document is added to the nest.
1685
+ Each segment is slugified (`GTM/Deals` → `gtm/deals`) and the depth cap applies.
1686
+ Creating a folder that already exists is a no-op. Write tier.
1687
+
1688
+ ```json
1689
+ { "folder": "gtm/deals" }
1690
+ ```
1691
+ Returns 201 with the path actually created.
1692
+
1693
+ ### GET `/nests/:nestId/census`
1694
+ One call for everything a nest overview needs to describe the *whole* nest,
1695
+ whatever folder is open — counts by tag, type and status, the folder tree, the
1696
+ author roster, and the recently-edited strip. Gated by the same `filterAccessible`
1697
+ pass as the listing, so the numbers describe what this caller can actually reach.
1698
+
1699
+ ```json
1700
+ {
1701
+ "census": {
1702
+ "total": 42,
1703
+ "folders": [{ "path": "specs", "count": 4, "total": 11 }],
1704
+ "tags": { "#api": 7 },
1705
+ "types": { "document": 40, "agent": 2 },
1706
+ "statuses": { "approved": 30, "draft": 12 },
1707
+ "authors": ["a@x.com"],
1708
+ "editedLast7d": 5,
1709
+ "recent": [{ "id": "nodes/foo", "title": "Foo", "type": "document", "updated_at": "..." }]
1710
+ }
1711
+ }
1712
+ ```
1713
+
1714
+ ### POST `/nests/:nestId/reindex` (write+)
1715
+ Force-rebuild the nest's document index from the vault, and return how many
1716
+ documents were indexed: `{ "indexed": 42 }`.
1717
+
1718
+ The index heals itself — anything that could leave it out of step with disk (an
1719
+ out-of-band edit, an import, a write that could not be mirrored) marks the nest
1720
+ stale, and the next read rebuilds before answering. This is the manual lever for
1721
+ when an operator wants that to happen *now*. A rebuild crawls the whole vault, so
1722
+ it is write-tier rather than something a read-only caller can trigger at will;
1723
+ below that the answer is `403`.
1724
+
1725
+ ### GET `/nests/:nestId/graph` (read+)
1726
+ Ontology graph — a read-only assembly over data the nest already holds, for the
1727
+ graph view (links / stewardship overlay / coverage gaps). Gated to what the
1728
+ caller can read (same `filterAccessible` pass as the other query routes).
1729
+
1730
+ Response `NestGraph`:
1731
+ ```json
1732
+ {
1733
+ "nodes": [{ "id": "nodes/alpha", "title": "Alpha", "type": "document", "tags": ["market-x"] }],
1734
+ "links": [{ "source": "nodes/alpha", "target": "nodes/beta" }],
1735
+ "stewards":[{ "id": "jane@acme.com", "label": "jane@acme.com", "role": "reviewer", "scope": "tag", "target": "market-x" }],
1736
+ "stewardEdges":[{ "steward": "jane@acme.com", "target": "tag:market-x", "scope": "tag" }],
1737
+ "tags": [{ "name": "market-x", "count": 2 }],
1738
+ "signals": {
1739
+ "orphans": [{ "id": "nodes/loose", "title": "Loose" }],
1740
+ "unstewardedTags": [{ "name": "market-y", "count": 1 }],
1741
+ "bottlenecks": [{ "steward": "jane@acme.com", "label": "jane@acme.com", "share": 67 }]
1742
+ },
1743
+ "truncated": false,
1744
+ "totalNodes": 3
1745
+ }
1746
+ ```
1747
+
1748
+ Notes:
1749
+ - **Identity masking** — steward `id`/`label` are the real email only for **write+**
1750
+ callers (same rule as the collaborators roster, so a public/read tier can't
1751
+ enumerate member emails). Sub-write callers get an opaque `"s1"` id and a
1752
+ `"Steward 1"` label; `bottlenecks[].label` follows suit.
1753
+ - **Roster gating** — `stewards`/`stewardEdges` are empty for public/anonymous
1754
+ readers and when the nest has stewardship disabled.
1755
+ - **Draw cap** — `nodes`/`links` are capped at 150 (highest-degree kept) with
1756
+ `truncated: true` and `totalNodes` set; `signals` are still computed over the
1757
+ full set so the gap numbers stay accurate.
1758
+
1759
+ ### GET `/nests/:nestId/runnables` (read+)
1760
+ Orchestrator surface: every **runnable** node (`type` `agent` or `skill`) with its
1761
+ `schedule` and body in one call, so an external scheduler can connect, discover
1762
+ what to run and when, and pull the prompt/skill content. Reuses the same
1763
+ per-caller gating as the node list (`listNodesForCaller`), so stewardship and the
1764
+ public-reader approved-only rule apply — a read-tier orchestrator key only ever
1765
+ sees **approved** agent/skill definitions; unapproved drafts are omitted.
1766
+
1767
+ ```json
1768
+ {
1769
+ "count": 1,
1770
+ "runnables": [{
1771
+ "id": "nodes/nightly-digest-agent",
1772
+ "title": "Nightly Digest Agent",
1773
+ "type": "agent",
1774
+ "schedule": "0 6 * * *",
1775
+ "status": "published",
1776
+ "tags": ["#ops"],
1777
+ "updated_at": "...",
1778
+ "content": "# Agent\n\n..."
1779
+ }]
1780
+ }
1781
+ ```
1782
+ - `schedule` is `null` when the node carries no schedule.
1783
+ - Filtering to runnable types happens before the per-node governance/version
1784
+ enrichment, so cost scales with the number of runnables, not total nest size.
1785
+
1786
+ ### POST `/nests/:nestId/publish`
1787
+ Bulk-create documents (and/or update `CONTEXT.md`) in one call. Each document
1788
+ routes through the same create path as `POST /nests/:nestId/nodes` — same slug
1789
+ derivation, same governance rows, same content-type policy gate (a disabled
1790
+ `artifact`/`table` type fails the whole batch up front, before any write).
1791
+
1792
+ Body: `{ documents?, context_md? }` — at least one required.
1793
+ `documents[]` rows are `{ title, content, type?, tags?, scope? }`; rows missing
1794
+ `title` or `content` are skipped silently (a shape problem, not a policy one).
1795
+
1796
+ A document whose title slugs to an id that already exists is **skipped and
1797
+ reported** — never overwritten, so republishing a folder cannot destroy an
1798
+ existing document's version history.
1799
+
1800
+ A document the create path rejects for its own reasons — a title that is too
1801
+ long or carries no sluggable characters, a `type` the engine does not know — is
1802
+ **reported in `failed` and the batch continues**. One bad row cannot abandon the
1803
+ rest half-written.
1804
+
1805
+ ```json
1806
+ {
1807
+ "published": 2,
1808
+ "context_md_updated": true,
1809
+ "node_ids": ["nodes/alpha", "nodes/beta"],
1810
+ "skipped": ["Existing Doc Title"],
1811
+ "failed": [{ "title": "Bad Row", "error": "..." }]
1812
+ }
1813
+ ```
1814
+
1815
+ Returns `201` whenever the batch ran, even if every row landed in `skipped` or
1816
+ `failed` — read the arrays, not the status, to know what happened.
1817
+
1818
+ `context_md` is written directly rather than through an operation, because none
1819
+ exists for it. It is therefore outside the vault write lock and has no `503` to
1820
+ return — a `context_md`-only request cannot report contention.
1821
+
1822
+ **Partial writes and retries.** The batch is not a transaction: documents are
1823
+ created one at a time and the ones written before a failure stay written. The
1824
+ one error that stops the batch early is the vault write lock being held past
1825
+ its acquire timeout, which answers `503` + `Retry-After`. Retrying is safe and
1826
+ finishes the job — every document from the first attempt now exists, so it
1827
+ comes back under `skipped` while the remaining ones are created. Expect a
1828
+ lower `published` count on the retry; that is the batch converging, not a
1829
+ failure.
1830
+
1831
+ ---
1832
+
1833
+ ## 8c. Publish links — public hosting of a single document
1834
+
1835
+ Expose ONE document as a standalone page at a stable, unguessable
1836
+ `/p/<slug>` URL. Distinct from nest visibility (whole-nest read) and from
1837
+ grants (authenticated per-user sharing). Managing publish links is owner/admin
1838
+ only. Access modes: `public` (no auth), `code` (shared passcode), `email`
1839
+ (viewer enters an email → one-time magic link → access logged), `invite`
1840
+ (same, but only allow-listed emails get a link). Every served view is logged;
1841
+ links are revocable and can carry `expires_at`.
1842
+
1843
+ ### POST `/nests/:nestId/publications` (owner/admin)
1844
+ Body: `{ node_id, access_mode?: "public"|"email"|"code"|"invite",
1845
+ comments_enabled?: bool, watermark?: bool, no_download?: bool,
1846
+ code?: string (code mode), allowlist?: string[] (invite mode),
1847
+ expires_at?: ISO }`. → `201 { publish, link }`.
1848
+
1849
+ `watermark` stamps the viewer's email across the page — email/invite gates only,
1850
+ since the other two identify nobody. `no_download` blocks copy, right-click,
1851
+ drag and printing; both are deterrents, not protection (screenshots and
1852
+ developer tools still work). `400` when `node_id` names no document in the nest,
1853
+ or when `email`/`invite` is requested on a server with no mail transport
1854
+ (`SMTP_URL` + `NOTIFY_EMAIL_FROM`) — their one-time link could never be sent.
1855
+
1856
+ ### GET `/nests/:nestId/publications` (owner/admin)
1857
+ List this nest's publish links with their `link` URLs.
1858
+
1859
+ ### GET `/nests/:nestId/publications/:id/views` (owner/admin)
1860
+ The access log — `{ count, views: [{ email, created_at }] }`.
1861
+
1862
+ ### DELETE `/nests/:nestId/publications/:id` (owner/admin)
1863
+ Revoke — the link 404s immediately.
1864
+
1865
+ ### Public render surface (no auth chain)
1866
+ - `GET /p/:slug` — the document, a gate form (email/code/invite), or — when
1867
+ `comments_enabled` — a reader shell framing the document beside the comment
1868
+ control. Document bodies are served under the strict artifact sandbox
1869
+ (opaque origin); markdown nodes are rendered server-side.
1870
+ - `GET /p/:slug/view` — the document itself, always sandboxed. The shell frames
1871
+ it; gated independently, so being the frame source is not authorization.
1872
+ - `POST /p/:slug/gate` — submit a code (unlock) or an email (send magic link).
1873
+ Rate limited per link per IP.
1874
+ - `GET /p/:slug/verify?token=` — consume a magic link → set session cookie → open.
1875
+ Invite mode re-checks the allow-list here, not only when the link was sent.
1876
+ - `POST /p/:slug/comments` — JSON `{ body, email? }`, from the shell's modal.
1877
+ Needs the gate cleared and an identifying email; lands as an unanchored
1878
+ annotation thread for stewards, never on the published page.
1879
+
1880
+ Only the approved version is ever served — a document with no approved snapshot
1881
+ 404s rather than exposing a draft. A revoked, expired or unknown slug answers
1882
+ every route above with the same HTML notice, never a hint about which it was.
1883
+
1884
+ ---
1885
+
1886
+ ## 9b. Workflow plane ("turing")
1887
+
1888
+ **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.
1889
+
1890
+ ### Edge types — the vocabulary of relations
1891
+
1892
+ - `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.
1893
+ - `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.
1894
+ - `DELETE /nests/:nestId/edge-types/:id` (write) — refused (`400`) while any edge references it.
1895
+
1896
+ ### Edges — typed, condition-carrying links
1897
+
1898
+ - `GET /nests/:nestId/edges?type=<name|id>&node=<id>` (read) — list, optionally filtered by edge type and/or an endpoint node.
1899
+ - `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.
1900
+ - `DELETE /nests/:nestId/edges/:id` (write).
1901
+
1902
+ ### Runs — the operational trace (server never executes)
1903
+
1904
+ - `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.
1905
+ - `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`.
1906
+ - `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").
1907
+ - `GET /runs/:id` (read) — full trace `{ run, steps[] }`. Freeform payloads (`run.inputs`, `run.trace`, `steps[].detail`) are redacted to `null` below write tier.
1908
+ - `GET /nests/:nestId/runs?agent=&status=&limit=` (read) — run history, newest first (same read-tier redaction).
1909
+
1910
+ > 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.
1911
+
1912
+ ### Schedules — run an agent on a timer
1913
+
1914
+ - `GET /nests/:nestId/schedules` (read) — list.
1915
+ - `POST /nests/:nestId/schedules` (write) — Body: `{ agent_node, every_minutes, enabled?, max_runs? }`. `max_runs` stops the schedule after that many runs.
1916
+ - `PATCH /nests/:nestId/schedules/:id` (write) — `{ every_minutes?, enabled? }`.
1917
+ - `DELETE /nests/:nestId/schedules/:id` (write).
1918
+
1919
+ Archived nests are skipped by the scheduler tick; restoring the nest resumes them.
1920
+
1921
+ ### Inbound hooks — let the outside world trigger a run
1922
+
1923
+ - `GET /nests/:nestId/hooks?agent=` (read) — list, tokens masked.
1924
+ - `POST /nests/:nestId/hooks` (write) — Body: `{ agent_node, preset }` with `preset` = `slack` | `teams` | `webhook`. Returns the secret URL once.
1925
+ - `DELETE /nests/:nestId/hooks/:id` (write) — accepts the full token or the masked id.
1926
+ - `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.
1927
+
1928
+ ### Env — per-nest secrets for tools and the runner
1929
+
1930
+ - `GET /nests/:nestId/env` (write+) — names and masked tails only; values are never read back.
1931
+ - `PUT /nests/:nestId/env` (write+) — `{ key, value }` creates or replaces.
1932
+ - `DELETE /nests/:nestId/env/:key` (write+).
1933
+
1934
+ Values ride to the runner inside the run bundle. `ANTHROPIC_API_KEY` here overrides the server-wide default from Settings.
1935
+
1936
+ ### Connectors — governance events out to a channel
1937
+
1938
+ Not behind the workflow-plane flag: connectors fire on review events too.
1939
+
1940
+ - `GET /nests/:nestId/connectors` (read).
1941
+ - `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 `["*"]`.
1942
+ - `PATCH /nests/:nestId/connectors/:id` (write) — `{ enabled?, events?, url? }`.
1943
+ - `DELETE /nests/:nestId/connectors/:id` (write).
1944
+
1945
+ ---
1946
+
1947
+ ## 9. MCP
1948
+
1949
+ ### `/nests/:nestId/mcp/*`
1950
+ Model Context Protocol endpoints — JSON-RPC over HTTP. Not exercised manually; LLM clients connect here.
1951
+
1952
+ **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).
1953
+
1954
+ **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.
1955
+
1956
+ **Every tool takes `client`.** The `{ agent, session_id, …custom }` block described in [Caller attribution](#caller-attribution-client) is advertised on every tool's input schema, reads included — a write that seals a version records it there (`context_versions` serves it back), and every call records it on the activity trace. It is never authenticated and never used to authorize. Omitted, the server fills what the connection reports (the API key's label, an `Mcp-Session-Id` header) under anything the caller sent, per key.
1957
+
1958
+ **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.
1959
+
1960
+ **`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.
1961
+
1962
+ **Deletion requests** mirror the REST + UI flow, so an agent isn't left at a dead end when `context_delete` refuses:
1963
+ - `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.
1964
+ - `context_deletion_queue` — pending (or `declined`) requests with their reasons and request ids.
1965
+ - `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.
1966
+
1967
+ **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:
1968
+
1969
+ - `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.
1970
+ - `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.
1971
+
1972
+ 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.
1973
+
1974
+ `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.
1975
+
1976
+ **`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.
1977
+
1978
+ Server-admin-only tools (gated by `isLicenseAdminUserId` in non-open mode, same as their REST counterparts):
1979
+ - `context_unsynced_list` — mirrors `GET /nests/unsynced`.
1980
+ - `context_sync_folder` — mirrors `POST /nests/unsynced/sync`. Args: `name` (folder path relative to `DATA_ROOT`).
1981
+
1982
+ Both reuse the same `listUnsyncedFolders` / `syncUnsyncedFolder` service functions the REST routes call, so a single fix lands in both surfaces.
1983
+
1984
+ ### Workflow-plane tools (only advertised when `FEATURE_WORKFLOW_PLANE=true`)
1985
+
1986
+ 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.
1987
+
1988
+ Build:
1989
+ - `workflow_edge_types_list` / `workflow_edge_type_upsert` / `workflow_edge_type_delete` — mirror the `/edge-types` routes.
1990
+ - `workflow_edges_list` / `workflow_edge_create` / `workflow_edge_delete` — mirror the `/edges` routes (DAG cycle guard + condition validation included).
1991
+
1992
+ Execute (the runner contract — the agent IS the runner):
1993
+ - `workflow_run` — trigger; returns the executable bundle (agent body + reachable subgraph + definitions + `run_id`).
1994
+ - `workflow_run_step` — append a step (server-assigned `seq`).
1995
+ - `workflow_run_close` — close with a terminal status (`409`-equivalent conflict on a second close).
1996
+ - `workflow_run_get` / `workflow_runs_list` — read traces/history (freeform payloads redacted below write tier).
1997
+
1998
+ ### PromptOwl native connection (service-ticket auth)
1999
+
2000
+ The official PromptOwl deployment connects on its users' behalf without storing
2001
+ a `cnst_` key: it mints a short-lived HS256 **service ticket** signed with the
2002
+ shared secret configured for it as an official community site (`/admin/community-sites`,
2003
+ or the legacy `OFFICIAL_COMMUNITY_SSO_SECRET` env var — see `CONFIGURATION.md`). The ticket
2004
+ carries `scope: "mcp"`, an `aud` bound to this server's `PUBLIC_BASE_URL`, a
2005
+ ~5-minute `exp`, and **no `jti`**. It resolves to an already-existing account by
2006
+ email — native access never auto-provisions.
2007
+
2008
+ Accepted as a `Bearer` on exactly these paths (everything else still requires a
2009
+ real credential):
2010
+ - `GET /index` and the server-level `ALL /mcp` — cross-nest index + read tools (including `context_comments`).
2011
+ - `POST /nests/:id/context` and `GET /nests/:id/nodes` — retrieval + doc listing.
2012
+ - `ALL /nests/:id/mcp` — the per-nest MCP transport, so a block granted read &
2013
+ write on one nest reaches that nest's write tools. Per-nest permission,
2014
+ suspend, and license gates all apply exactly as for a `cnst_` key.
2015
+
2016
+ Two **server-level bootstrap writes** on `ALL /mcp` let a single-connection
2017
+ client create content (everything else on server-level `/mcp` is read-only):
2018
+ - `nest_create` — new nest owned by the caller (refused for a nest-scoped key).
2019
+ - `context_create` — new document in a nest the caller can write to
2020
+ (`canCreateInNest`). Both honor the suspend kill-switch and license gate.
2021
+
2022
+ **Ticket kinds don't cross over.** An `mcp`-scoped ticket is refused at
2023
+ `GET /auth/sso` (it can't become a login session), and the MCP middleware
2024
+ refuses any ticket carrying a `jti` (the mark of a one-time SSO login ticket) —
2025
+ enforced on both sides, not by trusting the issuer.
2026
+
2027
+ ---
2028
+
2029
+ ## 10. Admin / operator
2030
+
2031
+ ### GET `/health` (no auth)
2032
+ `{ "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.
2033
+
2034
+ ### Superadmins — GET/POST `/admin/super-admins`, DELETE `/admin/super-admins/:email` (server-admin only)
2035
+ 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`:
2036
+
2037
+ - `license` — the PromptOwl account that owns the installed license. Derived live; never revocable here.
2038
+ - `config` — `access.yaml` `super_admins`. The authoritative bootstrap: **never revocable via the API** (lockout safety) — edit the file and restart instead.
2039
+ - `granted` — rows in the `super_admins` DB table, managed by these endpoints.
2040
+
2041
+ 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).
2042
+
2043
+ **GET `/admin/super-admins`** → `200`:
2044
+ ```json
2045
+ {
2046
+ "super_admins": [
2047
+ { "email": "owner@acme.com", "source": "license", "granted_by": null, "granted_at": null, "self": true },
2048
+ { "email": "ops@acme.com", "source": "config", "granted_by": null, "granted_at": null, "self": false },
2049
+ { "email": "jane@acme.com", "source": "granted", "granted_by": "owner@acme.com", "granted_at": "2026-07-17 14:03:22", "self": false }
2050
+ ]
2051
+ }
2052
+ ```
2053
+ An email present in several sources shows its least-revocable origin (`license` > `config` > `granted`). `self` marks the caller's own row.
2054
+
2055
+ **POST `/admin/super-admins`** with `{ "email": "jane@acme.com" }` → `201 { email, source: "granted", granted_by }`.
2056
+ - `400` — missing/malformed email, or no user account with that email exists on this server (invite them as a teammate first).
2057
+ - `409` — already an effective superadmin (any source).
2058
+
2059
+ **DELETE `/admin/super-admins/:email`** → `200 { ok: true, email }`. Guards:
2060
+ - `400` — target is the license admin, or an `access.yaml` entry (file is authoritative; remove from the file + restart).
2061
+ - `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.
2062
+ - `404` — email has no granted row.
2063
+
2064
+ 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.
2065
+
2066
+ ### Community sites — GET/POST `/admin/community-sites`, PATCH/DELETE `/admin/community-sites/:id` (server-admin only)
2067
+ 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**.
2068
+
2069
+ Same server-admin guard as `/admin/super-admins` (license admin, any effective superadmin; open mode: always allowed).
2070
+
2071
+ **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.
2072
+
2073
+ **POST `/admin/community-sites`** with `{ name, url, secret, is_active? }` → `201` with the row shape above (`is_active` defaults to `true`).
2074
+ - `400` — missing name, invalid/non-http(s) url, or a secret under 16 characters.
2075
+ - `409` — a site with this url already exists.
2076
+
2077
+ **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.
2078
+
2079
+ **DELETE `/admin/community-sites/:id`** → `200 { ok: true, id }`. `404` if the id doesn't exist.
2080
+
2081
+ 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.
2082
+
2083
+ ### GET `/admin/trace` (server-admin only)
2084
+ 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.
2085
+
2086
+ 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.
2087
+
2088
+ Query params (all optional):
2089
+ - `kind` — `api` or `mcp`. Omit for both.
2090
+ - `user` — substring match on caller email, or exact user id.
2091
+ - `nest` — filter to a single nest id.
2092
+ - `limit` — page size, clamped to `1..1000` (default `25`). Non-numeric values fall back to the default rather than erroring.
2093
+ - `offset` — rows to skip for pagination (default `0`). The UI pages in blocks of 25 (`offset = page * 25`).
2094
+
2095
+ `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.
2096
+
2097
+ Response (200):
2098
+ ```json
2099
+ {
2100
+ "count": 2,
2101
+ "total": 137,
2102
+ "limit": 25,
2103
+ "offset": 0,
2104
+ "events": [
2105
+ {
2106
+ "id": 412,
2107
+ "ts": "2026-07-03T13:58:44.812Z",
2108
+ "kind": "mcp",
2109
+ "method": null,
2110
+ "path": null,
2111
+ "tool": "context_overview",
2112
+ "nest_id": "nest_abc",
2113
+ "user_id": "usr_123",
2114
+ "caller": "alice@acme.com",
2115
+ "status": 200,
2116
+ "duration_ms": 14
2117
+ },
2118
+ {
2119
+ "id": 411,
2120
+ "ts": "2026-07-03T13:58:44.790Z",
2121
+ "kind": "api",
2122
+ "method": "POST",
2123
+ "path": "/nests/nest_abc/nodes",
2124
+ "tool": null,
2125
+ "nest_id": "nest_abc",
2126
+ "user_id": "usr_123",
2127
+ "caller": "alice@acme.com",
2128
+ "status": 201,
2129
+ "duration_ms": 23
2130
+ }
2131
+ ]
2132
+ }
2133
+ ```
2134
+ - `kind` — `api` (HTTP request) or `mcp` (tool call).
2135
+ - `method` / `path` — set on `api` rows; `null` on `mcp` rows.
2136
+ - `tool` — set on `mcp` rows; `null` on `api` rows.
2137
+ - `caller` — resolved caller email (from the row's stored email or a join on `user_id`).
2138
+ - `status` — HTTP status for `api` rows; `200` (success) / `500` (threw) for `mcp` rows.
2139
+ - `duration_ms` — wall-clock duration of the call.
2140
+
2141
+ Surfaced in the UI under the user menu → **Activity trace** (`ActivityTracePage`), with kind/user filters, 25-per-page pagination, and a refresh button.
2142
+
2143
+ ### GET `/nests/:id/trace` (nest owner / nest admin)
2144
+ 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.
2145
+
2146
+ 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`.
2147
+
2148
+ 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).
2149
+
2150
+ ---
2151
+
2152
+ ## Permission middleware quick-ref
2153
+
2154
+ For any `/nests/:nestId/...` request, the middleware computes:
2155
+ - `permission` = effective nest perm: `owner | admin | write | read | none`.
2156
+ - Required level by path:
2157
+ - `/collaborators`, `/visibility` → `admin`
2158
+ - `/reader-mode` → `admin` at the middleware, then the handler hard-gates on `isServerAdminUserId` (superadmin only)
2159
+ - Stewardship action paths (`/approve`, `/reject`, `/submit-review`, `/cancel-review`) → `read` (handler enforces fine-grained steward gating)
2160
+ - Other non-GET → `write`
2161
+ - GET → `read`
2162
+
2163
+ Returns 403 `Insufficient permissions` if not met. Open-mode (`AUTH_MODE=open`) treats everyone as `owner`.
2164
+
2165
+ License gate: any non-GET request OR any governance path requires a valid PromptOwl license (`/license/install`). 503 `License required` otherwise.
2166
+
2167
+ ---
2168
+
2169
+ ## Error shapes
2170
+
2171
+ | Status | When |
2172
+ |---|---|
2173
+ | 400 | Validation error (`{ "error": "..." }`) |
2174
+ | 401 | Auth required |
2175
+ | 403 | Permission denied (`Insufficient permissions`, governance steward mismatch) |
2176
+ | 404 | Nest / node / steward not found, or caller has no permission |
2177
+ | 409 | Conflict (e.g., key already exists, version conflict) |
2178
+ | 429 | Rate limit |
2179
+ | 503 | License missing |
2180
+
2181
+ ---
2182
+
2183
+ ## Quick test sequence (Postman)
2184
+
2185
+ 1. `POST /auth/login` → grab session cookie OR `POST /auth/keys` → copy `api_key`.
2186
+ 2. `POST /nests` → grab `nestId`.
2187
+ 3. `PATCH /nests/:nestId/settings` → `{ "stewardship_enabled": true }`.
2188
+ 4. `POST /nests/:nestId/nodes` → grab `node.id`.
2189
+ 5. `POST /nests/:nestId/stewards` → `{ scope:"document", email:"rev@x.com", role:"reviewer", nodePattern:"<node.id>" }`.
2190
+ 6. As `rev@x.com` → `POST /auth/register` (claims placeholder) → cookie stored.
2191
+ 7. `GET /nests` as reviewer → nest visible (proves auto-collab worked).
2192
+ 8. `GET /nests/:nestId/nodes` as reviewer → only the assigned doc visible (proves stewardship filter).
2193
+ 9. As original user → `POST /nests/:nestId/nodes/:nodeId/submit-review`.
2194
+ 10. As reviewer → `POST /nests/:nestId/nodes/:nodeId/approve` → 200.
2195
+ 11. `GET /nests/:nestId/nodes/:nodeId/versions` → confirm `resolvedBy` populated on the approved version.