@@ -490,3 +490,191 @@ Next: Stage 2, once someone can run a build. The check-in the user
490490asked for is the point at which this entry was written; Stage 1's code
491491should be built, vetted, formatted and tested before Stage 2 starts on
492492top of it.
493+
494+ ## 2026-09-15 — Tier 3, Stage 2 (metadata, webhooks, shipped events)
495+
496+ All four commands were run on ` feat/tier3-config-and-endpoints ` before
497+ each commit, and all four were clean:
498+
499+ ```
500+ go build ./... ok
501+ go vet ./... ok
502+ gofmt -l . (no output)
503+ go test -count=1 ./... config 0.031s httpapi 13.308s shiplog 0.006s
504+ templates 0.008s usermeta 0.008s webhook 0.029s
505+ ```
506+
507+ The Stage 1 entry ended by saying Stage 2 should not start until
508+ someone could run a build. The toolchain here recovered, so it did.
509+
510+ Four commits, one logical step each:
511+
512+ - ` d43a73d ` — ` usermeta/ ` , ` migrations/009 ` , the three admin routes and
513+ the claims-provider merge in ` main.go ` .
514+ - ` b3292c9 ` — ` webhook/ ` (store, sender, worker), ` migrations/010 ` , the
515+ deliveries endpoint, the ` WEBHOOK_* ` config and ` .env.example ` block.
516+ - ` bd2c990 ` — ` shiplog/ ` (store, logger), ` migrations/011 ` , the logging
517+ endpoint, and the ` MultiLogger ` composition in ` main.go ` .
518+ - ` cfbf29f ` — ` README.md ` and ` openapi/spec.yaml ` (1.3), then the three
519+ docs files.
520+
521+ ### What was built, and the decisions worth re-reading
522+
523+ - ** A repo-owned store is an interface, a Postgres implementation and an
524+ in-memory double, in one package.** ` usermeta ` , ` webhook ` and
525+ ` shiplog ` each follow cryden's own ` store/interfaces.go ` +
526+ ` store/memory ` + ` store/postgres ` split. That is what makes these
527+ endpoints testable with no Postgres — which matters more than usual
528+ here, because there is none in this sandbox.
529+ - ** The webhook delivery row is the queue, not a channel.** cryden calls
530+ ` SendWebhook ` synchronously on the login request path, so ` SendWebhook `
531+ writes one ` pending ` row and returns; the capacity-1 channel is only a
532+ nudge, and a full channel drops the hint rather than blocking. A
533+ channel would lose everything on restart, and "was that lockout
534+ announced" is unanswerable for an event that vanished before a row
535+ was written.
536+ - ** The body is built once, at enqueue, and stored.** Retries resend
537+ identical bytes, so the delivery log answers "what did we send" for a
538+ retry as well as a first attempt, and the signature covers the same
539+ bytes the log shows.
540+ - ** ` webhook_deliveries.id ` is a ` BIGSERIAL ` surrogate, not the event
541+ id.** The spec said ` id UUID PK ` . But ` notify.WebhookEvent.ID ` ** may
542+ be empty** — cryden generates it with ` crypto/rand ` and on generator
543+ failure deliberately delivers without one — and a delivery log whose
544+ primary key can be blank loses exactly the rows an operator most wants.
545+ The engine's id is recorded beside it as ` event_id ` . This is a
546+ deviation from the plan and is why it is written down.
547+ - ** "Shipped" means recorded in this repo's own table.** There is no
548+ vendor SDK here, so ` shipped_log_events ` holds the same bytes a hosted
549+ aggregator would have received, which is what makes it a stand-in for
550+ one rather than a second, different log beside it. Swapping in a real
551+ client is a change to one line of ` main.go ` .
552+ - ** The shipped-events sink writes synchronously, and that is a
553+ deliberate ceiling.** An asynchronous sink needs a buffer, a flush
554+ policy and a shutdown path, and this repo has no graceful shutdown
555+ anywhere yet. A buffer that is never flushed on exit is a log that
556+ silently drops its last records before a crash — for a log, the
557+ failure that matters most. ` LOG_LEVEL ` (default ` info ` ) is what keeps
558+ the volume sane meanwhile.
559+ - ** ` level= ` on the logging endpoint means "at or above".** The same
560+ direction ` logger.LevelFilter ` reads the word, so one word means one
561+ thing within one feature. The in-memory double filters ** by name
562+ against the same set the SQL passes** , so it is faithful by
563+ construction even for out-of-range levels, where ` Level.String() `
564+ clamps.
565+ - ** An unrecognized stored level name is filed at ` LevelError ` ** , not
566+ dropped. Failing a whole listing over one hand-written row would be a
567+ log an operator cannot read because of a typo in a row they were
568+ trying to inspect.
569+ - ** ` sink ` is recorded even though only one value is written today** —
570+ a row read out of a table shared with a second sink stays
571+ attributable.
572+ - ** Metadata key validation lives in ` usermeta ` , not ` httpapi ` .** The
573+ reserved-claim rule is a data invariant, so it holds for any writer.
574+ ` reserved_claim_names ` is reported by ` GET ` so a console can grey
575+ those out rather than let an operator discover the rule by rejection.
576+ Writes are per key, never a whole-map ` PUT ` , so two operators editing
577+ different fields cannot lose each other's work.
578+ - ** ` PUT ` 's body decodes ` value ` into a ` json.RawMessage ` , not an
579+ ` any ` .** ` {"value": null} ` and a missing ` value ` are different things,
580+ and decoding into ` any ` collapses both to nil.
581+ - ** A malformed ` userID ` is a ` 404 ` , not a ` 500 ` .** Handed straight to
582+ Postgres, ` "not-a-uuid" ` is a driver error — "invalid input syntax for
583+ type uuid" — which ` mapError ` turns into a 500 an operator reads as a
584+ bug in the API rather than as a stale bookmark.
585+
586+ ### Bugs found, and how
587+
588+ Three real ones, none of which a type check would have caught:
589+
590+ - ** ` WEBHOOK_MAX_ATTEMPTS ` set without ` WEBHOOK_URL ` did not fail
591+ startup.** The orphaned-setting check used ` os.LookupEnv ` , but the
592+ config tests' own ` loadForTest ` uses ` t.Setenv(name, "") ` — which
593+ * sets* the variable to empty — so six existing tests failed. The
594+ package's documented convention is that empty counts as unset
595+ everywhere, so the check became ` os.Getenv(...) != "" ` . Found by
596+ running the suite, which is the only thing that would have.
597+ - ** ` openapi/spec.yaml ` had never parsed as YAML.** ` APIKey.id ` 's
598+ description — ` What DELETE /api-keys/{keyID} takes. ` — sat unquoted
599+ inside a flow mapping, so the ` { ` opened a nested mapping and a parser
600+ stops there. Nothing had ever run the file through one; it was caught
601+ only because the 1.3 additions were validated. Fixed by quoting that
602+ one scalar, with a comment saying why. It is a syntax fix, not a
603+ contract change — no path, field or status code moved, so 1.3's
604+ "additive only" note stands.
605+ - ** ` webhook.Sender ` as a typed nil.** A nil ` *webhook.Sender ` assigned
606+ to cryden's ` notify.WebhookSender ` field is non-nil to cryden and
607+ would silently turn on ` DefaultWebhookEvents ` for a deployment with
608+ ` WEBHOOK_URL ` unset. ` main.go ` assigns the field inside the `if
609+ webhookStore != nil` block for exactly that reason, and the store is
610+ declared as the interface rather than the concrete type. The same
611+ class of trap ` logger.NewMultiLogger ` documents for untyped nils.
612+
613+ Two test bugs, both found by a red test and both the test's fault:
614+ ` TestWebhookDeliveriesFiltersByStatus ` resolved rows with ` ClaimDue ` ,
615+ which sweeps * every* due row, so its second row came back ` in_flight `
616+ rather than ` pending ` — the test now resolves before seeding; and
617+ ` TestParseLevelFilesAnUnknownNameAtTheMostSevereEnd ` asserted that
618+ ` "INFO" ` and ` "warning " ` were unknown, when ` logger.ParseLevel ` is
619+ case-insensitive, trims, and accepts the ` warning ` alias. The premise
620+ was wrong, not the code.
621+
622+ One naming collision, the same class as Stage 1's ` Deliveries ` :
623+ ` shiplog.Logger ` could not have both a ` Log ` method (the
624+ ` logger.ContextLogger ` interface dictates the name) and a ` Log ` field,
625+ so the field is ` Errors ` .
626+
627+ ### Verification: what this does NOT cover
628+
629+ Said plainly, per ` CODEX.md ` , rather than implied by a green suite:
630+
631+ - ** There is no Postgres and no network in this sandbox.**
632+ ` migrations/009 ` , ` 010 ` and ` 011 ` have ** never been applied to a real
633+ database** — not once, in any environment. They are a copy of a
634+ design, not a verified schema. Everything downstream of them is
635+ tested through the in-memory doubles.
636+ - ** The webhook worker's claim and backoff behaviour is not tested
637+ against Postgres.** ` ClaimDue ` 's single `UPDATE … WHERE id IN (SELECT
638+ … FOR UPDATE SKIP LOCKED)` statement has not been run. What is tested
639+ is the worker's behaviour against ` httptest ` and ` MemoryStore ` .
640+ - ** The in-memory double cannot reproduce two workers racing.** It is
641+ one mutex, so it proves the worker handles a claimed row correctly and
642+ proves nothing about contention. ` SKIP LOCKED ` is the reason raising
643+ the worker count later is safe, and that reason is unverified here.
644+ - ** ` internal/smoketest ` still has never been run** against a database,
645+ unchanged from every previous tier's note.
646+ - ** WebAuthn still needs a real browser authenticator, and Apple a live
647+ round trip.** Unchanged.
648+ - ** The ` usermeta ` claims path is tested for storage and for the merge,
649+ but the "reaches a freshly issued token" assertion runs on cryden's
650+ in-memory user store** , not on Postgres' ` user_metadata ` table.
651+ - ** ` shiplog ` 's Postgres ` List ` has not been run against the JSONB
652+ column it reads.** The ` lib/pq ` bytea trap (a ` []byte ` param is sent
653+ as bytea hex, which a JSONB column rejects, so params go as
654+ ` string(raw) ` ) is handled by reading rather than by a passing test —
655+ the ` Insert ` path that would exercise it needs a database.
656+
657+ Newly owed by this tier, alongside the three tables: ** the graceful
658+ shutdown the Stage 1 entry already flagged.** The webhook worker takes a
659+ ` context.Context ` and gets ` context.Background() ` ; the shipped-events
660+ sink writes synchronously precisely because there is nowhere to flush a
661+ buffer on exit. Both become cheap once shutdown exists and neither was
662+ smuggled in behind the other.
663+
664+ ### Noticed while working, not fixed
665+
666+ - ** ` openapi/spec.yaml ` still predates Tier 1** — unchanged from the
667+ Tier 2 and Stage 1 notes. Stage 2 added only its own schemas, paths and
668+ the 1.3 version bump; the gap is still there.
669+ - ** ` README.md ` 's "Design notes" now carries the repo-wide read-only
670+ rule as prose.** It is in ` CLAUDE.md ` as a rule; a reviewer reading
671+ only the README previously had no way to know why there is no retry
672+ button.
673+ - ** The delivery log and the shipped-events log both answer `404
674+ not_configured` when their store is nil** , which is a wiring fact. A
675+ client cannot currently tell that apart from "the resource genuinely
676+ does not exist" — the same shape every other unconfigured feature in
677+ this API already uses, so it is consistent rather than new.
678+
679+ Tier 3 is complete. Next is Tier 4, which stays read-only by
680+ construction with the pre-fill-never-auto-apply decision already made.
0 commit comments