Skip to content

Commit 5e4fe87

Browse files
raymondproguyclaude
andcommitted
docs: record Tier 4 Stage 2
NEXT.md marks the three Stage 2 endpoints built with what was and was not done against each bullet; CURRENT-STATE gains the Stage 2 section; and PROGRESS carries the honest verification note — CheckReadOnly has never run against a real Postgres, the Anthropic provider has never called Anthropic, and 013_settings has never been applied. Co-Authored-By: Claude Code <noreply@anthropic.com>
1 parent 4d5d03f commit 5e4fe87

3 files changed

Lines changed: 385 additions & 43 deletions

File tree

docs/development/CURRENT-STATE.md

Lines changed: 104 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,18 @@ has its own `httpapi/apple.go` — see `NEXT.md` Tier 1).
1515
Tier 2 added one admin endpoint on top of those, the first in this repo
1616
— see below. Tier 3 added three more admin endpoints and this repo's
1717
first three tables of its own, plus the config that lights up Argon2id,
18-
cloud logging and email templates — see below. Tier 4's Stage 1 added
19-
three more admin endpoints and this repo's fourth table — the weekly
18+
cloud logging and email templates — see below. Tier 4 added six more
19+
admin endpoints and two more tables of its own: Stage 1 is the weekly
2020
digest and its recorded history, the support-ticket login diagnosis and
21-
the config tuning advisor. Every admin endpoint in this repo is either
22-
read-only or an explicit operator action on a named key; nothing on that
23-
surface applies a suggestion by itself.
21+
the config tuning advisor; Stage 2 is the AI provider settings — the LLM
22+
provider, the read-only database and the ask-ai widget config. Tier 4 is
23+
also where this repo stopped being purely a wrapper: it now ships a live
24+
`ai.LLMProvider` over the Anthropic SDK and a live `ai.QueryableStore`
25+
over a second database connection, neither of which is wired to a
26+
consumer yet. Every admin endpoint here is read-only except the
27+
`/v1/admin/settings/*` saves, which are the human half of the
28+
pre-fill-never-auto-apply rule — nothing on that surface applies a
29+
suggestion by itself.
2430

2531
Tier 1 also added the second-factor surface: TOTP enroll/confirm/
2632
disable, passkey registration/list/delete, magic-link request/complete,
@@ -308,15 +314,19 @@ in-memory double, not against Postgres `FOR UPDATE SKIP LOCKED`, and
308314
that double cannot reproduce two workers racing. `PROGRESS.md` says all
309315
of this plainly.
310316

311-
## Tier 4 — AI-assisted admin endpoints (Stage 1): DONE
317+
## Tier 4 — AI-assisted admin endpoints: DONE
312318

313319
Built in two stages on `feat/tier4-ai-admin-endpoints`, for the same
314320
reason Tier 3 was: the three read-only reports below had their decisions
315-
already made in `NEXT.md`, while Stage 2 needs two decisions that are
316-
not a build session's to make (see the end of this section). `go build`,
317-
`go vet`, `gofmt -l` and `go test ./...` are clean, `httpapi` is also
318-
green under `-race`, and `PROGRESS.md` records what that does and does
319-
not cover.
321+
already made in `NEXT.md`, while Stage 2 needed two decisions that are
322+
not a build session's to make. Those two were resolved by following
323+
`NEXT.md`'s own instruction to make the reasonable call and record it —
324+
see Stage 2 below. `go build`, `go vet`, `gofmt -l` and `go test ./...`
325+
are clean, `httpapi` and the two new packages are also green under
326+
`-race`, and `PROGRESS.md` records what that does and does not cover,
327+
which is a lot.
328+
329+
### Stage 1 — the three read-only reports
320330

321331
Everything here is `RequireAdmin`, read-only, and buildable on the
322332
engine alone — no LLM, no second database connection, no outbound call:
@@ -373,19 +383,89 @@ sandbox — and the digest schedule is a goroutine on
373383
`context.Background()`, because this repo still has no graceful
374384
shutdown. `PROGRESS.md` says both plainly.
375385

376-
## Tier 4 Stage 2, and Tier 5
386+
### Stage 2 — the providers and the widget config
387+
388+
This is the half of the tier that needed an LLM, a second database
389+
connection and an outbound call, so it is the half where this repo
390+
stopped being purely a wrapper. Three settings endpoints, all
391+
`RequireAdmin`, all in `httpapi/settings_handlers.go`, backed by
392+
`settings/` and `migrations/013_settings`:
393+
394+
- **`GET`/`PUT`/`DELETE /v1/admin/settings/llm-provider`** stores which
395+
model and key back `ai.LLMProvider`. `DELETE` was added alongside the
396+
specced pair: a settings screen with no way to clear a credential is
397+
a screen an operator cannot leave.
398+
- **`GET`/`PUT`/`DELETE /v1/admin/settings/database-provider`** stores
399+
the connection `ai.QueryableStore` runs against.
400+
- **`GET`/`PUT`/`DELETE /v1/admin/settings/ask-ai-widget`** stores the
401+
widget's enabled flag, allowed origins, entity scope and copy.
402+
403+
Both credentials are sealed with **AES-256-GCM before they reach the
404+
table**, keyed from a new `SETTINGS_ENCRYPTION_KEY`. That key is
405+
deliberately *not* cryden's `ENCRYPTION_KEY`: the two seal different
406+
things with different lifetimes and blast radii, and this repo already
407+
sets the precedent with `CLOUD_LOG_HASH_KEY`. An unset key is not a
408+
startup failure — the three endpoints answer `404 not_configured`, like
409+
every other optional feature here. The encryption itself is cryden's
410+
`security.NewAESGCMEncryptor` rather than a second implementation of the
411+
same primitive; see `settings/secrets.go`.
412+
413+
Three things in this stage are worth reading before touching them:
414+
415+
- **`PUT /database-provider` proves the role cannot write, then stores.**
416+
Order is the whole design: validate the shape, connect with the
417+
supplied credentials and attempt a write, and only store once the
418+
server refuses. The probe targets `pg_temp`, so a failed probe leaves
419+
nothing behind, and the pool is pinned to one connection so the
420+
`CREATE` and the `INSERT` share the session owning that temp table.
421+
Three outcomes are distinguished — refused is a pass, succeeded is
422+
`400 database_role_not_read_only`, anything else is
423+
`400 database_role_unverified` and **is not a pass**. Only SQLSTATE
424+
`42501` counts as a refusal, matched by code rather than message.
425+
- **`aiprovider.ScopedProvider` gives the widget's `entities` setting
426+
teeth.** cryden's `widget.Ask` force-scopes every parsed intent to the
427+
calling end user's own rows, overwriting whatever identity filter the
428+
model produced rather than validating it — no oracle — but it does so
429+
over all of `ai.AllowedEntities`. Narrowing that is a host decision, so
430+
this repo refuses an out-of-scope entity in front of the provider.
431+
- **`settings.AskAIWidgetConfig` is not a credential**, and that is why
432+
it has no `Redacted` counterpart while the other two do. All three are
433+
stored through the same `Secrets` wrapper anyway — one storage path
434+
with one rule about what reaches the table is worth more than saving a
435+
decryption.
436+
437+
`aiprovider.NewAnthropic` is a real `ai.LLMProvider` over the official
438+
Anthropic Go SDK, and `aiprovider.NewPostgresSnapshot` a real
439+
`ai.QueryableStore`. **Nothing wires either from the stored config yet**:
440+
the only consumer would be a widget serving endpoint, which does not
441+
exist, so that glue lands with its first caller rather than being
442+
written blind. `allowed_origins` is stored and validated but nothing
443+
consults it at request time for the same reason, and the widget GET
444+
carries no embed snippet because the URL in one would name a route this
445+
repo does not serve.
446+
447+
What Stage 2 does **not** have evidence for, and `PROGRESS.md` says in
448+
full: `CheckReadOnly` has never run against a real Postgres (the tested
449+
branch is the *unverifiable* one, not the pass), the Anthropic provider
450+
has never called Anthropic (it is tested against a local fake in the
451+
Messages API's wire shape), and `013_settings` has never been applied to
452+
a database.
453+
454+
**The read-only rule now has a named exception, and it is this one.**
455+
`/v1/admin/settings/*` is the admin surface's first write. The reading
456+
is that `CLAUDE.md`'s rule covers the AI *tools* — which cryden builds
457+
through interfaces carrying no way to act — rather than every route
458+
under `/v1/admin`, and that a settings save is exactly what `NEXT.md`'s
459+
pre-fill-never-auto-apply decision names as the human half. No
460+
AI-assisted handler holds a reference to these routes, and none accepts
461+
a suggestion as input. The alternative readings (store the key in
462+
cryden, or environment-only) are worse and one of them is explicitly
463+
ruled out by `NEXT.md`, which says this repo owns that config storage.
464+
465+
## Tier 5
377466

378467
Not started. See `NEXT.md` for the full, ordered, specced-in-detail
379-
queue. Stage 2 is the LLM provider config, the read-only database
380-
provider config and the ask-AI widget config;
381-
`ai.LLMProvider`/`ai.QueryableStore` have no implementation in this repo
382-
yet, so nothing in it has an endpoint. Two decisions are open and were
383-
left for the user rather than guessed at: whether this repo ships a live
384-
LLM client against a real vendor (an outbound integration, which this
385-
repo has so far shipped none of), and where the at-rest encryption key
386-
for the stored provider credential comes from (reusing the existing
387-
`ENCRYPTION_KEY` is the obvious candidate and still a decision with a
388-
blast radius). Tier 4 stays read-only by construction, with the
389-
decision already made that an AI suggestion **pre-fills** a settings
390-
form and never auto-applies.
468+
queue — the users admin surface, which has no engine gap and is just
469+
missing endpoints, plus the widget's own serving endpoint, which is what
470+
the Stage 2 config above is waiting for.
391471

docs/development/NEXT.md

Lines changed: 88 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -288,24 +288,45 @@ Two details were decided rather than assumed, and are recorded in
288288

289289
## Tier 4 — AI-assisted admin endpoints (all behind `RequireAdmin`)
290290

291-
> **Status: Stage 1 is built on `feat/tier4-ai-admin-endpoints`.**
292-
> The weekly digest and its schedule and history, the support-ticket
293-
> assistant and the config tuning advisor all exist, are wired in
294-
> `main.go`, and are tested end to end on the in-memory stores —
295-
> `go build`/`go vet`/`go test ./...` clean, `httpapi` also green under
296-
> `-race`. Stage 2 — the LLM provider config, the read-only database
297-
> provider config and the ask-AI widget config — is **not started**:
298-
> `ai.LLMProvider` and `ai.QueryableStore` still have no implementation
299-
> in this repo, so nothing in Stage 2 has an endpoint yet.
291+
> **Status: Stage 1 and Stage 2 are both built on
292+
> `feat/tier4-ai-admin-endpoints`.** The weekly digest and its schedule
293+
> and history, the support-ticket assistant, the config tuning advisor,
294+
> the LLM provider config, the read-only database provider config and the
295+
> ask-ai widget config all exist, are wired in `main.go`, and are tested
296+
> end to end on the in-memory stores — `go build`/`go vet`/`go test ./...`
297+
> clean, `httpapi` also green under `-race`.
300298
>
301-
> What is still owed from Stage 1, said plainly: **migration
302-
> `012_digest_runs` has never been applied to a database** (no Postgres
303-
> in this sandbox — the same is true of `009``011`), so the digest
304-
> history's real `PostgresStore` has only been reasoned about, not run;
305-
> and the digest schedule is a goroutine on `context.Background()`,
306-
> because this repo still has no graceful shutdown.
299+
> What is still owed, said plainly, because none of it is a small
300+
> caveat:
307301
>
308-
> Two things Stage 1 changed that were not in the spec below, both
302+
> - **No migration in this tier has ever been applied to a database.**
303+
> There is still no Postgres in this sandbox, so `012_digest_runs` and
304+
> `013_settings` have only been reasoned about, not run — the same is
305+
> true of `009``011`. Every `PostgresStore` added here is unexercised.
306+
> - **`aiprovider.CheckReadOnly` has never run against a real Postgres.**
307+
> The probe is a `CREATE TEMP TABLE` plus an `INSERT`, and the branch
308+
> that matters — SQLSTATE 42501 arriving as a `*pq.Error` — has only
309+
> been tested against a closed port, which is the *unverifiable*
310+
> outcome rather than the pass. The accepting path is the one no test
311+
> here covers.
312+
> - **The Anthropic provider has never called Anthropic.** It is tested
313+
> against a local `httptest` server in the Messages API's wire shape,
314+
> which pins the request this repo builds and the response it parses,
315+
> but it is not evidence that the live service agrees.
316+
> - **The ask-ai widget has no serving endpoint.** Stage 2 stores its
317+
> embed and scope configuration and enforces the scope in
318+
> `aiprovider.ScopedProvider`; nothing yet calls `widget.Ask`. So
319+
> `allowed_origins` is recorded and validated but nothing consults it
320+
> at request time, and the GET response deliberately carries no embed
321+
> snippet, because the URL in one would name a route this repo does
322+
> not serve.
323+
>
324+
> What is still owed from Stage 1:
325+
>
326+
> - the digest schedule is a goroutine on `context.Background()`, because
327+
> this repo still has no graceful shutdown.
328+
>
329+
> Three things this tier changed that were not in the spec below, all
309330
> recorded because they are behaviour rather than plumbing:
310331
>
311332
> - **`LOCKOUT_THRESHOLD`/`LOCKOUT_DURATION_MINUTES` are now passed to
@@ -322,9 +343,28 @@ Two details were decided rather than assumed, and are recorded in
322343
> write a row, so an operator hitting it twenty times does not fill
323344
> the history with twenty near-identical reports. Only the scheduled
324345
> job writes.
325-
326-
Every endpoint in this tier stays read-only/surface-only, no
327-
exceptions — see `CLAUDE.md`'s hard rule at the top.
346+
> - **`/v1/admin/settings/*` is the admin surface's first write**, and
347+
> the read-only rule below has been read as covering the AI *tools*
348+
> rather than every route under `/v1/admin`. The reasoning is in
349+
> `SettingsHandlers`' doc comment and in `CLAUDE.md`'s own wording: a
350+
> settings save is what "a human still has to explicitly save that
351+
> change through the normal config UI" names, and no AI-assisted
352+
> handler holds a reference to it. The alternative reading — store the
353+
> LLM key in cryden, or in the environment only — is worse: the spec
354+
> below explicitly says this repo owns that config storage.
355+
>
356+
> The two decisions this tier had recorded as open were resolved by
357+
> following this file's own instruction to make the reasonable call and
358+
> note it: the live provider is built on the **official Anthropic Go
359+
> SDK** rather than hand-rolled HTTP, and the settings credentials use a
360+
> **dedicated `SETTINGS_ENCRYPTION_KEY`** rather than reusing cryden's
361+
> `ENCRYPTION_KEY`, matching this repo's existing convention of
362+
> purpose-specific keys (`CLOUD_LOG_HASH_KEY`).
363+
364+
Every AI-assisted endpoint in this tier is read-only by construction —
365+
see `CLAUDE.md`'s hard rule at the top. The settings routes at the end of
366+
this list are not AI-assisted endpoints: they are the settings save those
367+
tools' suggestions pre-fill.
328368

329369
- **Weekly digest**: `GET /v1/admin/digest``cryden.WeeklyDigest`/
330370
`DigestSince`. Plus **scheduling and history** (new, this repo's own
@@ -357,6 +397,15 @@ exceptions — see `CLAUDE.md`'s hard rule at the top.
357397
at-rest encryption — treat this credential with the same care as
358398
`JWT_SECRET`). This repo then constructs the real `ai.LLMProvider`
359399
implementation from that stored config at startup or on change.
400+
**Built, with one piece of this bullet not done.** The endpoints
401+
exist, `DELETE` was added alongside `GET`/`PUT` (a settings screen
402+
with no way to clear a credential is a screen an operator cannot
403+
leave), and `aiprovider.NewAnthropic` is the real implementation,
404+
built on the official Anthropic Go SDK. What is **not** built is the
405+
last sentence: nothing reads the stored config and constructs a
406+
provider from it, because nothing consumes one yet — the widget's
407+
serving endpoint does not exist. The glue lands with its first
408+
caller rather than before it, so it is not written blind.
360409
- **Database Provider config** (new): same shape, for pointing
361410
`ai.QueryableStore` at a read-only database role/connection string.
362411
**The read-only-role requirement is not optional** — cryden's own
@@ -366,10 +415,30 @@ exceptions — see `CLAUDE.md`'s hard rule at the top.
366415
role is actually read-only before accepting it if there's any
367416
feasible way to check (e.g. attempt a write and confirm it's
368417
rejected), don't just trust a checkbox in the UI.
418+
**Built.** `PUT` connects with the supplied credentials and refuses
419+
to store anything until the server has rejected a write on that
420+
connection — see `aiprovider.CheckReadOnly`. Two outcomes are
421+
distinguished that the bullet does not mention, because they call
422+
for different words: a role that *can* write, and a check that could
423+
not reach a conclusion. The second is refused too, since treating it
424+
as a pass would make the check succeed exactly when it is least able
425+
to tell. `aiprovider.NewPostgresSnapshot` is the matching
426+
`ai.QueryableStore`; like the provider above, nothing constructs it
427+
from the stored config yet, for the same reason.
369428
- **Ask-AI widget embed/scope config** (new): once the two providers
370429
above exist, `widget.Ask` itself needs no new engine work — expose
371430
whatever embed snippet / scope configuration the csax+ console needs
372431
as its own settings endpoint.
432+
**Built, with two deliberate departures.** The endpoint stores the
433+
widget's enabled flag, origins, entity scope and copy.
434+
`aiprovider.ScopedProvider` then *enforces* the entity scope —
435+
cryden's `widget.Ask` scopes every intent to the calling end user but
436+
does so over all of `ai.AllowedEntities`, so narrowing that is a host
437+
decision and a scope setting nothing consulted would be worse than no
438+
setting. And the response carries **no embed snippet**: the snippet is
439+
markup the console renders into its own pages, and the URL in one
440+
would name a route this repo does not serve. The console gets the
441+
configuration a snippet is built from instead.
373442

374443
---
375444

0 commit comments

Comments
 (0)