# smeldr.dev — Full Content Corpus > Generated by Forge on 2026-09-07 | Only published content | 94 items ## A PATCH route for typed content, and a labeling bug it almost shipped with - Smeldr URL: https://smeldr.dev/devlog/patch-route-and-a-surface-bug Published: 2026-09-05 The question that started this one was simple: could a Decision be ratified with `curl`? The answer turned up a real gap on the way there. Smeldr's typed content types, the ones you define with `smeldr.NewModule`, only had `PUT` over REST. Send a body missing a field, and that field gets zeroed, because `PUT` is a full replace by definition. Dynamic content already had a proper `PATCH` route with real partial-update semantics. MCP already had the same, through `MCPUpdate`. Typed REST was the one surface that never got one. ## The easy part: don't rebuild what already works `MCPUpdate` already does exactly what a `PATCH` handler needs: merge the new fields onto the existing item, restore identity and status afterward so a caller can't smuggle either through, validate, save. The new route's whole job was supposed to be decoding an HTTP body into the map `MCPUpdate` already accepts, plus the role check a REST route needs that an MCP tool gets from a different layer. Call `MCPUpdate`, done. ## Reading the code before calling it That plan was correct in outline and wrong in one specific way, and the only way to catch it was reading `MCPUpdate`'s own body rather than trusting its name. Buried in its final line: `m.notifyAfter(ctx, AfterUpdate, ..., surfaceMCP, ...)`. Hardcoded. Every caller of `MCPUpdate`, regardless of how it got there, ends up recorded as an MCP-originated update. That matters here because Smeldr has already done real, deliberate work threading `Surface` accurately through this codebase: fourteen separate call sites, each traced individually, each given the correct one of `"http"`, `"mcp"`, or `"trigger"`. `updateHandler`, the existing `PUT` route, gets this right: it passes `surfaceHTTP` at the equivalent point. A new `PATCH` route that called `MCPUpdate` directly would have quietly undone that work for every partial update: every provenance record, every audit trail entry for a `PATCH` request would say "mcp" when the actual caller never touched MCP at all. ## The fix, and proving it The merge logic itself didn't need to change, only who gets to say which surface it happened on. `MCPUpdate`'s body moved into a new function, `updateFields`, taking the surface as a parameter. `MCPUpdate` itself shrank to one line: call `updateFields` with `surfaceMCP`, exactly the value it always passed implicitly before. The new `PATCH` handler calls the same function with `surfaceHTTP`. The easy failure mode here is writing the fix and trusting it by inspection. Instead: a test that wires a real provenance store, subscribes to the real signal bus, sends a real PATCH request through the real HTTP handler, and asserts on what actually got recorded. Not a check that the plumbing exists: a check that this specific route uses it correctly. It failed the first time it was worth running, before the fix, and passed after. ## What stayed exactly the same A `PATCH` body can't change an item's status any more than it could before. Since the new route inherits `MCPUpdate`'s own restore-after-merge logic, a `Status` or `ID` field in the request body is silently discarded, identical to what `MCPUpdate` has always done. `transition_item` and `PUT` remain the only two ways to actually move an item's state. This wasn't a fresh decision for this task; it fell out of reusing the right logic rather than writing new logic that might have made a different, untested choice. --- ## Two tokens, one word: why your bootstrap admin token couldn't ratify anything - Smeldr URL: https://smeldr.dev/devlog/two-tokens-one-word Published: 2026-09-03 Smeldr has had an auto-bootstrap admin token since A83: boot a fresh instance with `ENABLE_TOKENS`, and if `smeldr_tokens` is empty, one gets minted for you automatically, logged once, ready to use. Convenient, and until this fix, quietly incomplete for one specific thing: it couldn't ratify or supersede a `Decision`. Same word, two different mechanisms. ## The word is "admin" A bearer token in Smeldr carries a `role` field, one of `"author"`, `"editor"`, `"admin"`, baked into the signed JWT at creation time. That field gates the generic Role hierarchy: create, publish, archive, the everyday CRUD surface. The bootstrap token has `role="admin"`, so it can do all of that. Separately, Smeldr's newer governance layer (`RoleStore`, `smeldr_role_grants`) grants roles to tokens explicitly, with scope. This is what D34's `Strict: true` transitions check: the ratify/supersede edges on `Decision`. `RoleGranted` queries `smeldr_role_grants` only. It never looks at the token's own `role` field. Two systems, one shared vocabulary. The bootstrap token's `role="admin"` satisfied the first and said nothing to the second. ## The ID mismatch underneath Fixing this meant granting the bootstrap token a role via `RoleStore.Grant`, which takes a `TokenID` to grant against. The obvious move, grant it against `smeldr_tokens.id`, is wrong. `RoleGranted` is checked against `ctx.User().ID`, the random ID embedded in the JWT's own claims at signing time. `smeldr_tokens.id` is a completely different value: the SHA-256 fingerprint of the raw signed token, used as the table's primary key. The two are computed independently and, before this change, never cross-referenced anywhere in the codebase. `TokenStore.Create` never surfaced the JWT's `User.ID`, only the raw token string. Recovering it after the fact meant decoding a token you just signed yourself, purely to read back a value you already had. ## The fix A small shared helper, `createToken`, factors `Create`'s existing logic out and returns the `User.ID` alongside the raw token. `Create`'s public signature doesn't change: it just discards the extra value. `ensureBootstrap` (unexported, so free to change) now returns that ID, and `App.Handler()` grants it `admin` via `RoleStore.Grant` in the same boot sequence, whenever governance is wired: ```go if created && a.governance != nil { a.governance.Grant(ctx, RoleGrant{TokenID: userID, RoleName: "admin"}) } ``` Fail-open, logged on error, idempotent: the same shape `ensureBootstrap`/`seedDefaultRoles` already established for this kind of thing. No new exported API. ## The takeaway If your app has two authorization systems that happen to share a role name, don't assume a value that satisfies one satisfies the other. Trace the actual check, not the field that looks like it should matter. The bootstrap token had "admin" written all over it and still couldn't do the one thing a governance-enabled instance exists for. --- ## The trigger that never fired: a five-week-old dead code path - Smeldr URL: https://smeldr.dev/devlog/the-trigger-that-never-fired Published: 2026-09-01 Smeldr's `TransitionTrigger` system lets you attach side effects to a state-flow transition, the built-in `schedule-eval` type, for example, reads a timestamp off an item and queues a future re-evaluation. Smeldr's own `Decision` type has used one since mid-July: ratify a Decision, and `schedule-eval` is supposed to queue its freshness check. It never ran. Not once, for any Decision, since the feature shipped. ## Two systems, one dispatcher Smeldr has two different ways content changes status. Runtime-defined "dynamic" content types go through `DynamicTypeRepo`, a generic path that works off a type name string. Smeldr's own built-in types, `Signal`, `Task`, `Decision`, `Amendment`, `Goal`, `Run`, are "typed" modules, compiled Go structs with their own dedicated methods: `updateHandler`, `MCPPublish`, `MCPSchedule`, `MCPArchive`, and a background scheduler, `processScheduled`. The function that actually fires a registered trigger, `fireAsyncTriggers`, had exactly two callers. Both were in the dynamic path. The five typed-module call sites, the ones `Decision` actually uses, never called it at all. ## Why nobody noticed Every one of those five call sites does real, working things: it validates the transition, saves the item, dispatches the ordinary lifecycle signals (`AfterPublish` and friends). A ratified Decision looks ratified. The UI shows it correctly. The audit trail records it. Nothing *visibly* breaks. The one thing that doesn't happen is a background write to a queue table nobody's watching directly. That's a genuinely quiet failure mode: nothing throws, nothing logs an error, the absence is just an absence. ## The fix, and why it's five lines, not five files Once you find the gap, closing it is almost anticlimactic: one call to the already-existing `fireAsyncTriggers`, added right after each site's own `Save` call, matching exactly how the dynamic-content path already does it. No new function. No new exported API. The interesting design question wasn't the fix. It was `MCPUpdate`, a sixth method that also changes content. Should it get the same call? No: `MCPUpdate` already, deliberately, restores an item's status to whatever it was before applying any update, its own doc comment says so. Inside that method, "before" and "after" status are always the same value. There is no transition to trigger anything from. Confirming that took reading one line of existing code, not writing one. ## What testing this correctly actually required The obvious test, call `fireAsyncTriggers` directly and check it fires, would have proven nothing new. That function already worked; the bug was that nothing typed ever *called* it. Every test for this fix drives a real `updateHandler` HTTP request, or a real `MCPPublish`/`MCPSchedule`/ `MCPArchive`/`processScheduled` call, and checks the trigger fired as a side effect of that. One test needed a second layer of care. `updateHandler`'s new call is guarded, skip it when status doesn't actually change, to avoid a wasted query on every ordinary edit. The three MCP methods aren't guarded at all. Proving that guard is real, and not just "nothing happened to match anyway," meant registering a trigger on the *exact* transition being skipped (a same-status self-loop) rather than a different one, otherwise the test would pass whether or not the guard code even existed. ## What this doesn't fix yet Fixing the trigger means a queue table starts filling up. Nothing drains it in the reference application yet: that's a second, separate gap, deliberately not shipped alongside this one. Automating a governance record's state change with no audit trail is a worse failure mode than a silently-empty queue ever was. That queue will now visibly, harmlessly accumulate until the observability gap closes too. --- ## IsTerminal isn't enough: why Task and Goal both needed a resolved state - Smeldr URL: https://smeldr.dev/devlog/honest-terminal-states Published: 2026-08-30 Smeldr's orchestration layer registers five governed `StateFlow`s: `Signal`, `Task`, `Decision`, `Amendment`, `Goal`, each with its own states and transitions, each state optionally marked `IsTerminal`. The doc comment on that field reads simply: "marks this state as a sink." Two of the five flows proved that wasn't a complete definition. ## The gap, found twice in one day `Task`'s flow runs `backlog → active → waiting-plan → plan-reviewing → implementing → commit-reviewing → done`. It has exactly one honest success path. What it didn't have: a way to close a Task whose own plan concludes "I looked, and this is already done, there's nothing to build." That's not hypothetical. A real Task (T238) sat stuck at `plan-reviewing` for two days after its own investigation found the work it was dispatched to do had already shipped under a different commit. The flow had one legal exit from that state, `implementing`, and nothing to build meant nothing to do there. The Task just... sat. The same day, a second and structurally sharper version of the same gap turned up in `Goal`'s flow. `Goal.parked` was declared `IsTerminal: true`. It also has a live transition back to `open`. Those two facts contradict each other: a state you can leave isn't a sink, whatever its own struct literal claims. Nothing had ever checked this: `IsTerminal` is written once at flow registration and never read again anywhere in the codebase. ## Same shape, different severity Both bugs are instances of one missing concept: a governed item can close because the underlying need was met by *something other than its own tracked work*. That's a real, distinct outcome from `done` (which specifically means this item's own work produced the result), and neither flow had a state for it. The fix, in both flows: a new `resolved` terminal state, reachable from every point that precedes real work actually starting: `active`/`waiting-plan`/`plan-reviewing` for `Task`, `open`/`in-progress`/`parked` for `Goal`, and nowhere else. `implementing`/`commit-reviewing` are excluded on purpose: once a build is genuinely in flight, "nothing to build" is no longer a live possibility by definition. Every transition into `resolved` requires a reason. The entire point of the state is an explanation of what actually resolved the need elsewhere, and a transition that skipped that explanation would just relocate the same problem T238 exposed, one level down. ## The stricter reading of IsTerminal wasn't true either While auditing the other three flows for the same shape, not assuming they were fine, checking each one's own registered transitions, a third, milder version of the bug turned up: `Decision.superseded` is marked terminal and has an outbound edge to `archived`. Same shape as `Goal.parked`'s bug, but far more benign: `archived` is *also* terminal, so the edge never leads back to live work, only between two closed states. `IsTerminal`'s own doc comment, read literally, forbade even that: "no outbound transitions are permitted from a terminal state," full stop. Decision's flow had quietly relied on a looser reading since it was written, and nothing had ever enforced the stricter one. Rather than invent a fix for an edge nothing was actually hitting, we corrected the comment to say what the codebase has always actually relied on: a terminal state may not transition to a *non-terminal* one. Terminal-to-terminal bookkeeping stays legal. ## What this doesn't change No exported Go symbol changed: `orchTaskFlow`/`orchGoalFlow` are both unexported functions, and the `State`/`Transition`/`StateFlow` types themselves are untouched. The behaviour change is real, though: `transition_item` and `get_valid_transitions` now report a `resolved` option for `Task` and `Goal` that wasn't there before. Patch bump, same class as any other behaviour-only fix with no new API surface. --- ## Why the Run type has no state flow (and why that's the point) - Smeldr URL: https://smeldr.dev/devlog/run-no-state-flow Published: 2026-08-28 Smeldr's orchestration layer has five typed content modules: `Signal`, `Task`, `Decision`, `Amendment`, `Goal`, and every one of them moves through a registered `StateFlow`. It's the natural pattern: define the states, define the transitions, let `validateTransition` gate the moves. `Run`, the sixth type, deliberately breaks that pattern. Here's why. ## What a Run is A `Run` is one mechanical episode of headless automated work, from the moment a listener claims a task to the moment it merges or gets abandoned. It's the coordination record for M3, Smeldr's headless-automation milestone: a webhook-fed process spawning `claude -p` unattended, with no human in the loop to notice a collision. ## The trap The obvious design is "a Run's lifecycle is just another state flow": `claimed → working → merged`, same shape as everything else. It's wrong, and the reason is structural, not stylistic. `validateTransition` and the state-flow machinery gate a *status field*. They check whether a transition is *allowed*, then write. That's two separate steps, a read-time check and then a write, and nothing atomic connects them. Two concurrent transitions from the same state can both pass validation and both apply. For most content that's fine: a human is either directly involved or reviewing shortly after. For a `Run`, it's the whole problem. Two listeners racing to claim the same row is exactly the failure this type exists to prevent. ## What actually has atomicity `SQLRepo.Save` does: a real `INSERT ... ON CONFLICT ... WHERE rev = $N` compare-and-swap, the only one in the framework. It's how the CAS-backed five orchestration types already avoid write races on ordinary field updates. It just isn't wired to `Status`. So `Run`'s design inverts the usual approach: claim, renew, and reassign are all `Save` calls guarded by that CAS, writing directly to two new fields, `LeaseHolder` and `Outcome`, never `Status`. `Node.Status` is still there (every content type carries it), but it's inert. No flow is registered for `Run`, so nothing gates it, and no Run row is ever published; it just sits at `Draft` for its entire life. Reads aren't affected: `list_run`/`get_run` don't filter by draft-visibility the way some other surfaces do. ## The trap within the trap The nearest real precedent, `smeldr/agent`'s `AgentJob`, registers a state flow and routes its lifecycle through `Status`. It's a reasonable thing to copy and the wrong one for this case: `AgentJob` doesn't have `Run`'s concurrent-claim problem. Precedent that looks adjacent isn't always precedent that applies. ## The part we can't test Every lease-touching write has to echo the `rev` value it last read. Skip that, and the framework's own update path silently seeds the current row's `rev` into the write, satisfies the compare-and-swap by construction, and the whole design degrades to last-write-wins, with no observable difference under a single-threaded test. That's not a gap we're leaving quietly. It's stated directly in `Run`'s own doc comment, because the code that actually performs claims and renewals, the M3 listener, doesn't exist yet. This task built the type, the storage, and the registration. The discipline that keeps it correct is a contract for whoever builds the listener next, not something this layer can enforce or verify on its own. --- ## trace_lineage: walking a decision back to what it actually rests on - Smeldr URL: https://smeldr.dev/devlog/trace-lineage Published: 2026-08-07 Decisions build on decisions. When an agent doubts something three hops downstream, tracing that doubt back to its actual premise means walking the chain, not guessing at it. That's `RelationStore.TraceLineage` now. ## What it is ```go trace, err := relationStore.TraceLineage(ctx, "decision", "d42", 5) // trace.Nodes = every item found walking depends_on/derives_from edges upstream // trace.Truncated = true only if the walk genuinely had more past maxDepth ``` Each `LineageNode` carries the edge that reached it (`RelationKind`, `EdgeClass`, `Confidence`), whether that edge is currently invalidated, and whether the node itself has since been superseded. Three guards, non-negotiable: a visited-set prevents a cyclical graph from looping; hitting `maxDepth` sets an explicit `Truncated` flag rather than a silent cutoff; an invalidated edge is followed, not stopped at — flagged in the result, because the invalidated edge is usually where the actual answer lives. ## The one genuinely open question, resolved If the walk reaches a Decision that was itself superseded, does it stop there having noted the item is stale, or follow on to the replacement? `TraceLineage` follows it — on the same reasoning already accepted for invalidated edges: stopping would hide exactly what the query was asked to find. The replacement is recorded at the *same* depth as the item it replaced, not an extra hop out — a decision's revision history is metadata about its own identity, not a new premise in the reasoning chain. ## Reuse, not reinvention — but not a straight copy either The obvious precedent was `Reachability` (the general bounded graph-traversal primitive, shipped a few weeks earlier): same package, same `RelationEdge` type, already solving cycle detection with a visited-set BFS. Building `trace_lineage` from a different, more distant technique (a batched-fetch pattern from an unrelated composition-edge table) instead of that closer precedent would have missed real, already-solved work sitting one file away. What `Reachability` doesn't have is a batched query per BFS depth level — it queries once per frontier node, fine at the widths seen so far, but not the shape to build new code on deliberately. `trace_lineage` adds that: one query per depth level, grouped by node type, instead of one query per node. ## Two bugs a coverage pass actually caught Worth naming, because both were wrong in ways that looked plausible until a test proved otherwise: - The first `Truncated` implementation assumed "frontier non-empty at `maxDepth`" meant "there's more." It doesn't — the nodes found at the boundary might simply have no further edges of their own. Fixed with a one-time peek past the boundary that doesn't record anything, just answers the question honestly. - The first supersede-following implementation found a node's *immediate* replacement and stopped — so a Decision revised twice would trace to the first revision, not the current one. Fixed by chasing the chain until it actually runs out, which then needed its own bound (a pathologically long revision history could otherwise run unbounded) — capped at the same `MaxLineageDepth` ceiling as the main walk. --- ## Decision authority wasn't missing. It was three unset switches. - Smeldr URL: https://smeldr.dev/devlog/decision-authority-not-a-missing-feature Published: 2026-08-07 Nothing about ratifying or superseding a governance `Decision` required new capability. The role/grant system, the per-transition role gate, the state machine — all of it already existed and already worked. It was just never switched on for these two specific transitions, and the switch itself had three ways to silently do nothing when flipped. ## An unset column, not a missing mechanism `orchDecisionFlow()`'s `proposed → ratified` and `ratified → superseded` transitions were registered as bare `{From, To}` pairs — no `RequiredRole`. `validateTransition` already reads a `required_role` column and checks it against the caller's grants via `RoleGranted`. The two transitions that matter most for authority just never had anything in that column to check. That alone wasn't the whole gap. `validateTransition` had three separate fail-open branches guarding the role check itself: a nil `RoleStore` (governance not wired), an empty actor ID (no authenticated caller), and — the one that was easy to miss — the `smeldr_transitions` lookup query itself erroring for a reason other than "no such row." All three returned `nil` (allowed) unconditionally, for every transition, regardless of whether that transition had a role set. Setting `required_role` on the two `Decision` transitions without fixing this would have shipped a gate that looked real and wasn't: a `SQLITE_BUSY` under write contention or a deployment that forgot to wire `App.Governance` would let the transition through, silently, exactly when the property "no unratified decision counts as a premise" matters most. The fix is a new `Strict` field on `Transition`, opt-in per row — every existing transition keeps today's lenient behaviour unchanged — plus one global change: the query-error branch now fails closed for every transition, strict or not, since it runs before `Strict` is ever consulted. Shipping the `Strict` column alone, without that second change, would not have closed the gap it was built to close. ## The check that would have been silently wrong The `admin`-role check is layered onto `updateHandler` — the one call site that can currently move a `Decision` through a status change at all (`MCPUpdate` explicitly restores the existing status after decoding a request, so no MCP tool reaches this path today). `updateHandler` decodes a full-replace `PUT` body into a fresh struct, not a merge — so a request body that omits a field zeroes it in the decoded item. The first version of a second, scope-aware authorization layer read that decoded item's own `Scope` field to decide which role to require. A test written specifically to exercise that branch — grant the actor the generic role, withhold the scope-specific one, expect a 403 — came back 200 instead. The decoded item's `Scope` was empty, because the test's request body only set `Status`. The check wasn't failing to reject; it was checking the wrong copy of the item; against the value the request was *about to write*, not the value describing what the item *currently is*. Fixed by checking the item as loaded from the repository before decoding, not after — which also closes a real path where a caller could otherwise pair a status change with a favorable scope change in the same request and have the check see the wrong one. ## What actually enforces it today The scope-aware layer ships, but empty — no per-scope role policy has been decided yet, so it's a deliberate no-op for now, a mechanism ready for a future decision rather than a rule of its own. What's live today is the plain, generic gate: `Decision` ratify and supersede require the `admin` role, full stop. Whether the actual account that has been ratifying decisions up to now holds that role is a live deployment fact no amount of reading the source code can confirm — worth checking before this reaches anyone whose next ratify attempt would otherwise be the first time they find out. --- ## A pattern that looked identical in two places — and worked in only one - Smeldr URL: https://smeldr.dev/devlog/provenance-actorkind-and-the-wrapped-context Published: 2026-08-07 Two call sites, one helper function, the same three-line pattern for recovering an authenticated actor from a `context.Context`. Copy the pattern from the place it already works, apply it to the place it's missing, done — except the second call site silently never worked, and the only way to find that out was to write the test the plan called for and watch it fail. ## The gap that looked like a missing feature `App.Provenance()` records who did what to a piece of content — but its `ActorKind` field only ever said `"human"` or `""`. Meanwhile, the relation graph's own provenance recording (`relations.go`) already distinguished a job-driven relation assertion from a human one, via a field (`RelationEdge.CreatedByJob`) set directly on the data being written. Same underlying capability, two call sites, one of them working and one not. The obvious fix: give `SignalEvent` — the payload every lifecycle-transition subscriber receives — a way to carry the same signal. But there's no equivalent "edge" to attach a field to on a lifecycle transition; the actual identity information already lives on the authenticated `ctx.User()`. And `relations.go` already has a working pattern for recovering exactly that from a bare `context.Context`: a type assertion, `ctx.(Context)`, that succeeds because `smeldr.Context` embeds `context.Context` and Go interfaces keep their full concrete method set when passed through an interface-typed parameter. So: copy the pattern. Add the same type assertion inside `App.Provenance()`'s signal handler. It compiles. It reads correctly. It matches an established, already-reviewed precedent in the same codebase. ## Why it doesn't work anyway `App.Provenance()`'s handler isn't called the way `relations.go`'s is. `relations.go` calls its provenance-recording function directly, inline, synchronously, from the same function that just wrote the database row — the `ctx` it receives is the exact one the original caller passed in. `App.Provenance()`'s handler, on the other hand, runs through the signal bus: `dispatchBus` wraps the incoming context in `context.WithoutCancel`, then `context.WithTimeout`, before calling each subscriber. Both are completely ordinary standard-library context wrappers — and both return their own private struct types that embed the parent `context.Context` but implement nothing beyond it. The rich `smeldr.Context` interface — `User()` and everything else — doesn't survive the wrap. `ctx.(Context)` inside the handler silently returns `ok == false`, every time, in production, forever. Two call sites that use the identical three lines of code. One of them crosses an async dispatch boundary that strips the very thing those three lines depend on. The only way to have caught this before shipping was to write the test the plan said to write, and run it, before writing anything else: a job-tagged actor triggering a real lifecycle transition, asserting that the resulting `ActorKind` came back `"job"`. It came back `"human"`. That one failing assertion was worth more than any amount of re-reading the two code paths side by side. The fix moves the capture point earlier, to somewhere the wrapping can't reach: `buildSignalEvent`, which already runs synchronously, before dispatch, to build `ActorID` and `ActorRole` from the same `ctx.User()` call. A new field, `SignalEvent.ActorRoles`, captured right there, read directly in the handler — no recovery attempt needed at dispatch time at all. Every existing hand-built `SignalEvent{}` test literal kept passing without a single edit; a nil `ActorRoles` behaves exactly like today's default. ## A second thing the same test surfaced Wiring `App.Provenance()` into a real running instance — the other half of this change — needed a test that actually exercised the full path a real server uses: build the app, start an HTTP server, create something over MCP, check that a record landed. The test found zero records. Not a wiring bug in `Provenance()` this time — `App.Handler()` itself never wires the signal bus at all. Only the blocking `App.Run()` does. Any caller who embeds Smeldr's `http.Handler` in their own server — which is a documented, intended way to use it, not a workaround — gets a completely inert signal bus: no webhooks, no audit trail, no provenance, silently. The fix looks small — call the same wiring function from `Handler()` too — but it isn't quite a one-line patch. The first attempt guarded it to run once, matching every other lazy one-time setup already in `Handler()`. That broke a different way: content types registered *after* that first call never got wired at all, because the guard fired before all of them existed. The right shape turned out to be the opposite of "once" — re-run it every time `Handler()` is called, since it's cheap and nothing about it can double-register anything. Neither of these was found by reasoning about the code. Both were found by writing the test the task already called for, running it, and treating a failure as information rather than an obstacle to route around. --- ## Devlog: The PUT That Bypassed Everything (A217) - Smeldr URL: https://smeldr.dev/devlog/t150-update-handler-state-governance Published: 2026-08-07 **Amendment:** A217 **Date:** 2026-07-15 --- We just closed a quiet but significant gap in Smeldr's state governance layer. The fix is small — seven lines — but the gap it closes was hiding behind a test that looked correct but wasn't. ## What was wrong Smeldr's state flow system enforces two things: (1) transitions must follow registered edges in the flow graph, and (2) certain transitions can require a specific role (`RequiredRole`). Both checks live in `validateTransition`. The lifecycle methods — `MCPPublish`, `MCPArchive`, `MCPSchedule` — all call `validateTransition`. The MCP update path (`MCPUpdate`) sidesteps it by design: it restores the status from the existing record, making it impossible to change status through an MCP update at all. But the HTTP update path (`PUT /{prefix}/{slug}`) did neither. It decoded the request body into a fresh item, preserved the ID and Slug from the existing record, and then saved whatever `Status` value the caller submitted. No call to `validateTransition`. No role check. Any authenticated caller with write access could `PUT` any content item to any status string — including states that don't exist in any flow, and transitions that require elevated roles. The fix is a seven-line guard added after `prevStatus` and `newStatus` are resolved: ```go if prevStatus != newStatus { if err := validateTransition(ctx, m.db, m.roleStore, ctx.User().ID, m.contentTypeName, string(prevStatus), string(newStatus)); err != nil { WriteError(w, r, err) return } } ``` Fail-open semantics (nil DB, no flow registered, non-SQLite) are preserved — matching every other call site. ## The test that concealed the gap `TestModule_updateHandler_unpublish` tested exactly this code path — a PUT that transitions a Published item to Draft. It asserted 200 OK and that the saved item had status Draft. The test passed. But the test module had no database (`m.db == nil`). `validateTransition` checks the DB first; if it's nil, it returns nil immediately (fail-open). So the test was silently bypassing the exact validation path the fix adds. A published→draft transition would have been rejected in production by `validateTransition` — except it wouldn't, because of the second gap. ## The second gap: published→draft was missing from the default flow After writing the fix, I checked whether the test would actually pass against a real migrated database. The answer was: no, it wouldn't — because `published → draft` was not in the default flow's transition list. The default flow had five transitions: draft→scheduled, draft→published, scheduled→published, published→archived, draft→archived. Unpublishing (`published → draft`) was never added. This is an independent gap in the default flow itself, not a side effect of T150. The fix adds it as the sixth transition: ```go {"published", "draft"}, ``` The `ON CONFLICT DO NOTHING` insert in `migrateStateFlows` makes this additive and safe for existing production instances. The test was updated to use a real migrated SQLite database. Now it exercises the full path — `validateTransition` runs, finds the `published → draft` edge, and allows the transition. If either gap had remained open, the test would have failed. ## What this means for developers If you're using Smeldr with a state flow configured and a database wired in, **HTTP PUT requests that attempt to change status now go through the same governance gate as the lifecycle methods.** An invalid target state returns 409. A missing transition edge returns 409. A transition that requires a role you don't have returns 403. If you're running without a database (`m.db == nil`), the behaviour is unchanged — fail-open, as before. If you're using the default flow and have content you'd like to unpublish, the `published → draft` edge is now in the default migration. Existing instances will pick it up automatically on next boot — the insert is idempotent. --- ## Closing the orchestration state-validation gap - Smeldr URL: https://smeldr.dev/devlog/t148-state-validation-gap Published: 2026-08-07 **Amendment A216 — T148** When we ran the T147 data migration last week, we discovered that 14 amendments had been created with `status="done"` — a state that exists in the goal and task flows but not in the amendment flow (`scoped / in-progress / commit-ready / committed / merged / rejected`). The MCP `create_amendment` tool accepted the call without error, stored the item, and returned success. The root cause: Smeldr has always validated state *transitions* at transition time (via `validateTransition`), but never validated the *initial* state at create time. Any string that happened to be a valid state in *any* registered flow was accepted silently. ### What changed Two gaps are now closed: **Create-time (Gap 1).** Both `createHandler` (HTTP POST) and `MCPCreate` (the MCP create path) now call `validateInitialState` after field validation, before the item is persisted. If the caller supplies a `status` value that is not a registered state in the type's own flow, the request is rejected with a 409 Conflict. **Transition-time (Gap 2).** `validateTransition` now checks whether the *target state* exists in the flow before looking up the transition edge. Previously, a transition to a non-existent state and a transition to a valid-but-unreachable state both produced the same generic "transition not permitted" error. Now, the first case produces a specific "not a valid target state" message — easier to diagnose, especially for AI agents that have no other signal. ### Fail-open design Both checks are fail-open on structural errors: nil DB, non-SQLite, missing flow, query failure. This matches the existing convention for all state-flow checks in Smeldr — enforcement degrades gracefully rather than blocking all creates when the DB is unavailable. ### Lesson If your Smeldr instance uses custom state flows (or the built-in orchestration flows for decisions, amendments, goals, tasks, and signals), the `status` field on any create request is now validated against that type's own flow. Passing a state that belongs to a different type's flow will result in a 409, not a silent success. No exported Go symbols changed. No version bump required. --- ## Closing the gaps in dynamic content: validation, scheduling, and AI indexing - Smeldr URL: https://smeldr.dev/devlog/t104-phase-b-dynamic-content-gaps Published: 2026-08-07 **Release:** smeldr.dev/core v1.54.0 · Amendment A202 --- When we shipped the dynamic content substrate in v1.41.0 (A153), `DynamicTypeRepo` accepted any `map[string]any` you handed it — no validation against the registered schema. You could create a recipe without a title, or pass a number where the schema expected a string, and the data would silently persist. That was the pragmatic call at the time. This release closes those gaps. ## Field validation on create and update Two new functions in `schemas.go`: ```go // create path — all required fields must be present err := smeldr.ValidateFields(schema, fields) // update path — only provided fields are checked (partial update semantics) err := smeldr.ValidatePartialFields(schema, patch) ``` Both return `*ValidationError` when the input doesn't conform to the schema: unknown fields, missing required fields, type mismatches. Both return `nil` when `schema` is `nil` — so if you have dynamic types without a schema, nothing breaks. `DynamicTypeRepo.CreateDraft` and `UpdateFields` now call these automatically. You don't need to call them yourself — they're there for cases where you want to validate a field map before handing it to the repo. ## Scheduling support `DynamicTypeRepo` now has a `ScheduleContent` method alongside `SetStatus`: ```go err = repo.ScheduleContent(ctx, id, time.Now().Add(48*time.Hour)) ``` It uses the same `validateTransition` logic as `SetStatus` — if your type has a registered state flow that doesn't include `draft → scheduled`, you'll get `ErrConflict`. State-flow enforcement is consistent across the whole lifecycle. ## AI index at boot If you have runtime-defined content types with a `URLPrefix`, their `/llms.txt` compact fragment is now wired at boot time when `loadDynamicTypes` runs. Previously, the AI index was only populated for compiled `Module[T]` types. Dynamic types were invisible to the AI index until content was published and a refresh was triggered. Now they're registered on startup. --- *v1.54.0 ships with 96.1% test coverage.* --- ## Decision Freshness: Scheduled State Re-evaluation in Smeldr v1.47.0 - Smeldr URL: https://smeldr.dev/devlog/decision-freshness-eval-queue Published: 2026-08-07 Smeldr v1.47.0 ships `TransitionTrigger` and `App.DrainEvalQueue` — a lightweight mechanism for scheduling automatic state transitions in the future, with no cron-at-definition-time complexity. ## The problem Governance workflows need time-based state cycling. A Decision ratified today should automatically surface for re-evaluation in six months. Previously, there was no way to express "when this item transitions to state X, schedule a follow-up transition at time Y" — you had to poll the database yourself. ## What shipped `TransitionTrigger` is a new struct on `StateFlow`: ```go err := app.RegisterFlow(smeldr.StateFlow{ Name: "governance-decision", TypeName: "Decision", // ... states and transitions ... Triggers: []smeldr.TransitionTrigger{ { FromState: "proposed", ToState: "ratified", TriggerClass: "async", TriggerType: "schedule-eval", Config: `{"eval_field":"next_eval_at","to_state":"pending-re-evaluation"}`, }, }, }) ``` When a Decision transitions `proposed → ratified`, Smeldr reads the `next_eval_at` field from the Decision row and inserts a queued entry into `smeldr_eval_queue`. The queue persists across restarts. `App.DrainEvalQueue` processes due entries: ```go triggered, skipped, err := app.DrainEvalQueue(ctx) ``` Or wire it automatically: ```go sch, err := agent.NewEvalQueueScheduler("", "UTC", app) // runs every 5 minutes sch.Start() defer sch.Stop() ``` ## Design decisions **Fail-open everywhere.** If `next_eval_at` is null or empty, the trigger silently skips — no errors, no blocked transitions. The eval queue is best-effort; a missed drain doesn't corrupt state. **Direct SQL UPDATE, not `SetStatus`.** `DrainEvalQueue` writes directly to the item's table rather than going through the full `SetStatus` path (which would re-fire triggers, causing infinite loops). The trade-off: signal hooks and conflict checks don't run on drain. This is intentional for the re-evaluation use case. **Inline interface in the agent module.** `NewEvalQueueScheduler` accepts an interface `{ DrainEvalQueue(ctx) (int, int, error) }` rather than `*smeldr.App`, avoiding a circular import and keeping the agent module self-contained. ## The governance cycle `orchDecisionFlow` (the built-in Decision orchestration type) is now wired with two triggers — one for `proposed → ratified` and one for `pending-re-evaluation → ratified`. Every time a Decision is ratified, a re-evaluation is scheduled. Every time it's re-evaluated and re-ratified, the cycle restarts. This means a live governance system never silently accumulates stale decisions. --- ## State flow tools land in smeldr.dev/mcp - Smeldr URL: https://smeldr.dev/devlog/state-flow-mcp-tools Published: 2026-08-07 smeldr.dev/mcp v1.24.0 ships three new tools that expose the T23 custom state flow infrastructure to MCP clients. ## What shipped - `transition_item(type_name, slug, to_state)` — moves a dynamic content item to a new state. The transition is validated against the registered flow; if the transition is not permitted, the tool returns -32001 with the specific rejection reason. Requires Editor role. - `get_valid_transitions(type_name, slug)` — returns the item's current state and the list of states it can legally transition to. Uses the type's registered custom flow, falling back to the default flow (draft → scheduled/published/archived) when no custom flow is registered. Requires Author role. - `list_items_by_state(type_name, state)` — returns all items of a dynamic content type in the given state. Useful for building inboxes, dashboards, or queues from the MCP interface. Requires Author role. All three tools are gated on the app's database being configured (`App.Config().DB != nil`). No new server option is required. ## How to use ```go // Register a custom flow for your type at startup: app.RegisterFlow(smeldr.StateFlow{ Name: "review-flow", TypeName: "proposal", States: []smeldr.State{ {Name: "draft", IsInitial: true}, {Name: "in-review"}, {Name: "approved", IsTerminal: true}, {Name: "rejected", IsTerminal: true}, }, Transitions: []smeldr.Transition{ {From: "draft", To: "in-review"}, {From: "in-review", To: "approved"}, {From: "in-review", To: "rejected"}, // RequiredRole is stored in smeldr_transitions but not yet enforced — // per-transition role gating is planned for a later T23 step. }, }) ``` Then from the MCP interface: ``` // Check what's possible next: get_valid_transitions("proposal", "my-proposal-slug") // → {current_state: "draft", valid_transitions: ["in-review"]} // Move it forward: transition_item("proposal", "my-proposal-slug", "in-review") // → {slug: "my-proposal-slug", status: "in-review"} // Query the review queue: list_items_by_state("proposal", "in-review") // → {type_name: "proposal", state: "in-review", items: [...], count: N} ``` ## The fix that comes with it `errorFor` in the MCP server now correctly maps `smeldr.ErrConflict` to JSON-RPC `-32001` with the specific error message. Previously, a transition conflict from `set_content_status` would have produced a generic `-32603 "internal error"`. This is now fixed as a side effect of the state tools landing. --- ## Content Relations in MCP: six new tools - Smeldr URL: https://smeldr.dev/devlog/relation-mcp-tools Published: 2026-08-07 The relation graph tools are now wired in `smeldr.dev/mcp`. If your app calls `app.Relations(store)`, six tools become available automatically — no new server option needed. ## The six tools | Tool | Role | What it does | |------|------|--------------| | `assert_relation` | Author | Create an asserted edge. Not idempotent — each call produces a unique edge. Use `get_relations` first to check for duplicates. | | `propose_relation` | Author | Create an inferred edge for human/agent review. Stays `edge_class="inferred"` until promoted. | | `get_relations` | Author | Query edges by source, target, or both. Filter by `kind` and `edge_class`. | | `preview_impact` | Editor | See what would receive `AfterRelationCascade` before archiving or deleting an item. Read-only. | | `upsert_relation_kind` | Admin | Register or update a relation kind. Idempotent on `type_name`. | | `list_relation_kinds` | Author | Inspect the kind registry. | ## Wiring ```go smeldr.CreateRelationTables(db) store, _ := smeldr.NewRelationStore(db) app := smeldr.New(cfg).Relations(store) mcpSrv := mcp.New(app) // all six relation tools wired automatically ``` No `mcp.WithRelations()` option. The tools appear only when `app.RelationStore() != nil`. ## Why assert_relation is not idempotent `MCPAssertRelation` calls `insertEdge`, which generates a new UUID for every call. The `ON CONFLICT` clause in the DB only guards the edge's own ID — two calls with identical endpoints create two edge records. This is by design: the relation graph supports multiple parallel edges between the same pair (different confidence scores, different `created_by_job` values). Call `get_relations` first if you want to avoid duplicates. ## Output format All tools return snake_case field names (`source_type`, `edge_class`, `relation_kind`, etc.) even though the underlying `RelationEdge` struct uses `db:` tags. The MCP layer applies local conversion helpers to keep the output consistent with every other Smeldr tool. --- ## Per-path SEO overrides without touching the content model - Smeldr URL: https://smeldr.dev/devlog/page-meta-seo-override Published: 2026-08-07 # Per-path SEO overrides without touching the content model ## The gap Every content item in Smeldr can implement `Head()` — the framework calls it when rendering the detail page, the sitemap entry, and the RSS item. That covers `/posts/my-article` well. It covers `/posts` — the list page — poorly. A list page has no single "item" to ask. The old fallback was `SiteConfig.og_image` plus whatever was in `OGDefaults`. On most sites that meant every list page shared the site-wide og:image and a generic title. Not great for a blog's `/essays` or a documentation section. The alternatives were `ListHeadFunc` — a per-module Go function that returns a `Head` — and the global `SiteConfig`. `ListHeadFunc` works well but requires code: each module needs an option set at startup. There was no way to adjust an SEO override after deploy without a redeploy. ## The solution: a database-backed override layer `PageMetaStore` is a thin SQL table (`smeldr_page_meta`, four columns) that maps URL paths to SEO field overrides. Operators populate it through the MCP server — four Admin tools (`set_page_meta`, `get_page_meta`, `delete_page_meta`, `list_page_meta`) — or through `App.GetPageMeta` in a custom handler. The store sits at a specific position in the fallback chain for list pages: ``` ListHeadFunc (Go code, highest priority) → PageMetaStore (DB-backed, operator-managed) → global SiteConfig / OGDefaults (lowest priority) ``` Detail pages are unchanged — they call `Head()` on the item itself, then fall back to `SiteConfig`. The override layer is list-page-specific because list pages are the ones without an owning item. ## Three small design decisions **`Get` returns a zero value, not `ErrNotFound`.** The caller pattern is: look up the path; if no override is stored, skip it. Returning an error for a missing row puts the error check at every call site. Returning a zero `PageMeta` and nil error means the caller checks `meta.Path != ""` — one field test, no error case. This follows the same convention as `PageMeta{}` being the meaningful empty value. **`ListHeadFunc` takes priority over the store.** This is the code-over-data rule. If a developer has written a Go function that computes the list-page head, it knows more than a database row does. The store is for operators who cannot or should not redeploy; code wins when both are present. **`INSERT OR REPLACE` for upsert.** SQLite's `ON CONFLICT DO UPDATE` (UPSERT) is cleaner in standard SQL but harder to write across drivers. `INSERT OR REPLACE` is SQLite-idiomatic, has identical semantics for this table (path is the primary key), and requires no extra clause. The store wraps `smeldr.DB` which already works with both SQLite and PostgreSQL; the PostgreSQL variant will migrate to `ON CONFLICT DO UPDATE` if pgx support is extended. ## Wiring ```go // once at startup smeldr.CreatePageMetaTable(db) store := smeldr.NewPageMetaStore(db) app.PageMeta(store) // in mcp.go srv := mcp.NewServer(cfg, mcp.WithPageMeta(db)) ``` `App.Handler()` injects the store into every template module via the same push-loop pattern already used for nav trees and SEO defaults. No module needs to know the store exists — it just starts having its list-page head populated. ## What this replaces Nothing is removed. `ListHeadFunc` still works and takes priority. `SiteConfig` is still the last fallback. The new layer adds a knob that operators can turn from chat without touching the codebase. For sites where list-page SEO was an afterthought, it is now a two-minute fix. --- ## Renaming the wire without breaking it - Smeldr URL: https://smeldr.dev/devlog/rename-wire-level Published: 2026-08-07 # Renaming the wire without breaking it ## The problem Renaming a project is mostly find-and-replace: the package, the import paths, the docs. We had already done that. What was left was the part that find-and-replace can't safely touch — the identifiers that leave the process and that someone else's code depends on by their exact spelling: - the **HMAC-signed webhook headers** a receiver verifies *by name*, - the **MCP resource URIs** an agent may have already listed and cached, - the **environment variables** sitting in someone's CI config. Rename any of these naively and you don't get a compile error. You get a silent breakage in production, in code you don't own. So the rule for this slice was: the new identifier is generated and preferred; the legacy one is still accepted and emitted alongside it; nothing breaks. Removal comes later, deliberately (tracked as T87), once integrations have migrated. But "dual-emit everything" is the lazy answer. The three families carry very different risk, and that shaped how much each one was worth. ## The high-risk one: signed webhook headers A webhook receiver verifies authenticity by computing an HMAC over the payload and comparing it to the signature header. Crucially, it looks the signature up *by header name*. Rename `X-Forge-Signature` to `X-Smeldr-Signature` and every existing receiver fails verification — not with an error you'll see, but by silently rejecting (or worse, silently accepting) deliveries. So the signature is computed once and sent under both names, with identical values: ```go // outbound.go — dual-emitted during the T86 deprecation window req.Header.Set("X-Smeldr-Signature", sig) req.Header.Set("X-Smeldr-Timestamp", strconv.FormatInt(ts, 10)) req.Header.Set("X-Smeldr-Event", job.Event) req.Header.Set("X-Smeldr-Delivery", job.ID) req.Header.Set("X-Forge-Signature", sig) // same value req.Header.Set("X-Forge-Timestamp", strconv.FormatInt(ts, 10)) req.Header.Set("X-Forge-Event", job.Event) req.Header.Set("X-Forge-Delivery", job.ID) ``` `sig` is the same `sha256=` string in both. An existing receiver keeps verifying `X-Forge-Signature` and never notices; a new one verifies `X-Smeldr-Signature`. This is the low-visibility, high-breakage corner — nobody sees these headers in a UI, but get it wrong and you break auth. It is the part of the sweep that was pure cost, done for completeness, not for value. ## The low-risk one: MCP resource URIs The opposite case. Every AI agent that browses resources sees the URI scheme, so it is highly visible — but it is also transient: an agent lists resources and reads one in the same breath, so a cached `forge://` URI is rare and short-lived. The new scheme is generated: ``` smeldr://posts/{slug} ``` and the parser accepts both, so any agent still holding a `forge://` URI resolves fine: ```go // resource.go // parseResourceURI resolves a smeldr:// or forge:// URI to its module and slug. // Accepts the new smeldr:// scheme (preferred) and the legacy forge:// scheme // (still accepted during the deprecation window — T87 removes it). ``` High visibility, low breakage: generate the new, accept both, move on. The MCP server also now identifies itself as `smeldr-mcp` in `serverInfo` — checked first that nothing keyed on the old name. ## The boring one: environment variables The CLI now prefers `SMELDR_URL` / `SMELDR_TOKEN` / `SMELDR_MCP_URL` and falls back to the `FORGE_*` names if only those are set; `init` writes a `.smeldr-cli.env`. Anyone's existing CI keeps working untouched; new setups use the Smeldr names. ## The principle Three families, one rule, calibrated by risk: | Identifier | Visibility | Breakage risk | Treatment | |------------|-----------|---------------|-----------| | Webhook HMAC headers | Low | **High** (verified by name) | dual-emit, identical values | | MCP resource URIs | High | Low (transient) | generate new, accept both | | CLI env vars | Low | Medium | prefer new, fall back to legacy | In every case: the new identifier is generated and preferred, the legacy one is accepted/emitted alongside, and nothing breaks. The one thing that is *not* in this release is the removal — dropping `forge://`, ceasing to emit `X-Forge-*`, dropping the `FORGE_*` fallback. That is a deliberate, separately-communicated breaking change for later (T87), once the telemetry says integrations have moved. Renaming the wire is safe precisely because the rename and the removal are two different events, with a deprecation window in between. --- ## Content Relations, Layer 3: SweepStructural - Smeldr URL: https://smeldr.dev/devlog/content-relations-layer-3 Published: 2026-07-18 # Content Relations, Layer 3: SweepStructural The first two posts in this series covered two of the three correctness layers for Smeldr's relation graph: the save-path sync that fires on every write, and the reactive cascade that fires when a target item changes state. Together they handle almost everything. But "almost" is the word that matters. Consider what they do not cover: - A bulk import that writes directly to the database, bypassing the save path. - A database restore from backup that puts items in a state the running application never saw. - A signal that was lost because the server restarted mid-delivery. - A manual deletion performed by an operator directly in SQLite. In any of these cases, stale edges accumulate silently. A relation still records that post A depends on source B, but source B no longer exists or is no longer published. Neither Layer 1 nor Layer 2 will ever notice, because neither of them saw the event that made the edge stale. Layer 3 is the answer: a periodic structural sweep. ## SweepStructural `RelationStore.SweepStructural` iterates all active edges, those where `invalid_at` is null or in the future, and `valid_at` is null or in the past, and calls a `TargetChecker` for each unique target: ```go // TargetChecker reports whether a relation target is still live. // Error means the check could not be performed — the edge is skipped, not flagged. type TargetChecker func(ctx context.Context, targetType, targetID string) (alive bool, err error) ``` When a target is not alive, the sweep stamps `invalid_at = now` on each source edge pointing to that target and calls `onStale` for each one: ```go flagged, skipped, err := store.SweepStructural(ctx, check, func(ctx context.Context, e smeldr.RelationEdge) { // called once per stale edge log.Printf("stale: %s/%s -> %s/%s via %s", e.SourceType, e.SourceID, e.TargetType, e.TargetID, e.RelationKind) }) ``` The function groups edges by target before calling the checker, so a target with fifty source edges is checked once. Errors from the checker are logged at Warn level and increment the `skipped` counter; they never abort the sweep. `flagged` is the count of edges whose target was found not alive. ## App.SweepStructural `App.SweepStructural` is a convenience wrapper with a built-in `TargetChecker` for runtime-defined content types. It queries `smeldr_dynamic_content` by ID and checks `status = 'published'`. For each stale edge it fires `AfterRelationCascade`, so existing signal handlers receive the notification through the same path as Layer 2: ```go flagged, skipped, err := app.SweepStructural(ctx) ``` The default checker only covers runtime-defined content types. Applications that also use compiled content types (via `Module`) need to call `store.SweepStructural` directly and supply a `TargetChecker` that queries their own tables. ## SweepScheduler The agent module provides [`SweepScheduler`](/docs/sweep-scheduler) to run the sweep on a cron schedule. It wraps gocron with singleton mode (`LimitModeReschedule`), which means if a sweep is still running when the next tick arrives, the tick is rescheduled rather than starting a second concurrent sweep: ```go sweep, err := agent.NewSweepScheduler( "0 * * * *", // 5-field cron: every hour at :00 "Europe/Copenhagen", app.SweepStructural, ) if err != nil { log.Fatal(err) } sweep.Start() defer sweep.Stop() ``` `SweepFunc` is the function type accepted by `NewSweepScheduler`: ```go type SweepFunc func(ctx context.Context) (flagged, skipped int, err error) ``` `App.SweepStructural` satisfies `SweepFunc` directly, so no adapter is needed. The scheduler logs at Debug level when there is nothing to report and at Info level when flagged or skipped is non-zero. `Stop` calls `Scheduler.Shutdown` and waits for any in-flight sweep to finish before returning. The timezone parameter is an IANA location string. Empty string defaults to UTC. This matters if your sweep schedule aligns with business hours: a daily sweep at midnight local time is `"0 0 * * *"` with the correct location, not UTC midnight. ## Why invalid_at and not delete Stale edges are stamped with `invalid_at` rather than deleted. This preserves the history of the relation graph. An audit trail, a dashboard showing that an item was once depended on by X others, or a governance workflow that reviews stale edges before removing them: all of these need the edge records to persist. `invalid_at` takes the edge out of the active set without destroying it. Hard delete is always available via `store.Delete(ctx, edgeID)` when the application wants it. ## Acting on stale edges When `App.SweepStructural` marks an edge as stale, it fires `AfterRelationCascade` for the source item via the same signal path as Layer 2. Any handler registered with `App.OnSignal(AfterRelationCascade, ...)` will receive it. The handler does not need to know whether the notification came from a reactive cascade or from the sweep. Common patterns in that handler: - Queue a review job so an editor can decide whether to update the dependent item, re-point it to a replacement, or archive it as well. - Notify via webhook so an external system can react. - Trigger an AI agent to propose a replacement target or a corrected field value. - Set a derived field on the dependent item (`has_stale_relation: true`) so the dashboard can surface items that need attention. The sweep result is also a graph health metric. `flagged > 0` after a run means something changed outside the normal save path: an import bypassed the hook, a restore happened, or an item was deleted via the database directly. A monitoring system that watches the flagged count gives early warning when the graph drifts between scheduled sweeps. If the application needs to act on stale edges at sweep time rather than via the signal bus, call `store.SweepStructural` with a custom `onStale`: ```go flagged, _, err := store.SweepStructural(ctx, check, func(ctx context.Context, e smeldr.RelationEdge) { // hard-delete the stale edge immediately _ = store.Delete(ctx, e.ID) // or push to a review queue reviewQueue.Enqueue(e.SourceType, e.SourceID) }) ``` `App.SweepStructural` fires `AfterRelationCascade` and stamps `invalid_at`. `store.SweepStructural` gives you the raw callback and the same stamp, without touching the signal bus. Both are available; the choice is whether stale edges discovered by the sweep should flow through the same handler as reactively-detected ones, or be handled separately. ## The full picture The three layers give the relation graph a layered correctness guarantee: | Layer | Trigger | Latency | Coverage | |-------|---------|---------|----------| | Layer 1: save-path sync | Every save | Synchronous | Source-side writes | | Layer 2: reactive cascade | Target state change | ~500ms (debounced) | Observed lifecycle events | | Layer 3: structural sweep | Cron (e.g. hourly) | Periodic | Everything else | No single layer is sufficient. Together they cover the full space: fast on the common path, reactive on observed events, and corrective on everything that slipped through. --- Reference: [SweepScheduler](/docs/sweep-scheduler) · [Content Relations](/docs/content-relations) --- ## Content Relations, Layer 2: AfterRelationCascade - Smeldr URL: https://smeldr.dev/devlog/content-relations-layer-2 Published: 2026-07-16 # Content Relations, Layer 2: AfterRelationCascade The save-path sync from the first post in this series keeps the relation graph consistent when a source item is saved. But what happens when a target item changes? If a source that a hundred blog posts cite is archived, none of those blog posts were just saved. Their save hooks will not fire. The graph will correctly record that the edges exist, but the dependents have no way of knowing their referenced item just changed state. Layer 2 answers this: the `AfterRelationCascade` signal. ## What triggers it When `App.Relations` is called, it subscribes a handler to four signals: `AfterPublish`, `AfterArchive`, `AfterDelete`, and `AfterUnpublish`. These are the signals that mean a target item's lifecycle state changed. Whenever one of them fires, the handler looks up all source-side items that have an edge pointing at the target via `GetByTarget`: ```go // from buildCascadeHandler in relations.go return func(ctx context.Context, ev SignalEvent) error { edges, err := store.GetByTarget(ctx, ev.Type, ev.NodeID, "") if err != nil { return err } if len(edges) == 0 { return nil } // ... fire AfterRelationCascade for each unique source } ``` `ev.NodeID` is the ID of the item that just changed state. `GetByTarget` returns all edges where that item is the target, that is, all items that depend on it. For each unique source item in that set, the handler fires `AfterRelationCascade`. ## Three guards A naive implementation would fire a cascade for every edge. Three guards prevent this from becoming a problem. **Visited-set.** Within a single handler invocation, each `(sourceType, sourceID)` pair is notified at most once. If a blog post cites the same source via five different relation kinds, it still receives one `AfterRelationCascade` signal. **Idempotency-set.** Each `(edge.ID, previousState)` pair is processed at most once per handler call. This protects against duplicate edges arriving via the same query. **Depth = 1.** The handler subscribes to status-change signals (`AfterPublish`, `AfterArchive`, etc.), not to `AfterRelationCascade` itself. Transitive cascades are not triggered automatically. If post A cites source B, and source B cites document C, archiving C notifies B. It does not automatically notify A. A is notified when B changes state, but that requires B to actually change state, not just receive a signal. This is a deliberate design choice. Transitive cascade storms are a known failure mode in reactive systems. Depth 1 keeps the behaviour local and predictable. An application that needs transitive notification can handle `AfterRelationCascade` on B and re-emit signals as needed. ## Debounce Multiple edges can point from the same source to different targets. If three targets change state in rapid succession, the source would otherwise receive three `AfterRelationCascade` signals within milliseconds. The handler debounces per source item using a `sync.Map` of per-source debouncers with a 500ms window: ```go key := visitKey // "sourceType:sourceID" d, _ := debouncers.LoadOrStore(key, newDebouncer(500*time.Millisecond, func() { debouncers.Delete(key) app.emitSignal(baseCtx, AfterRelationCascade, cascadeEv) })) d.(*debouncer).Trigger() ``` A `LoadOrStore` is safe here: the map stores the first debouncer for a given source key, and subsequent calls to `Trigger` on the same debouncer reset its timer. After the window expires, the debouncer fires, deletes itself from the map, and emits exactly one `AfterRelationCascade` for that source item. No matter how many targets change state simultaneously, each source item receives at most one cascade signal per 500ms window. ## Handling the signal Register a handler via `App.OnSignal`: ```go app.OnSignal(smeldr.AfterRelationCascade, func(ctx context.Context, ev smeldr.SignalEvent) error { // ev.Type is the content type of the dependent item. // ev.NodeID is the ID of the dependent item. // ev.ActorID is the ID of the target item whose state changed. // ev.PreviousState is the signal that triggered the cascade // (e.g. "after_archive"). log.Printf("item %s/%s depends on %s, which just fired %s", ev.Type, ev.NodeID, ev.ActorID, ev.PreviousState) // Common actions here: re-validate, queue a review job, update // a derived field, or emit a notification to an editor. return nil }) ``` `ev.PreviousState` carries the name of the triggering signal as a string, so the handler can distinguish between a target that was archived and one that was deleted. `ev.ActorID` carries the ID of the target item that changed. The handler does not receive the target item itself; if the handler needs it, it must look it up explicitly. ## What this enables The most direct use is impact preview before a destructive action. An editor is about to archive a source item. Before doing so, the application calls `store.GetByTarget(ctx, "source", sourceID, "")`, which returns all source-side dependents without firing any signals — the same data the [`preview_impact`](/docs/content-relations-mcp#preview_impact) MCP tool exposes to agents and operators. The editor sees "12 items depend on this" and can decide whether to proceed. When the archive action happens, `AfterRelationCascade` fires for each of those 12 items. Handlers can queue a review job, flag the item in the editor's dashboard, or re-run validation. The framework delivers the notification; what the application does with it is up to the application. The third post in this series covers what happens when Layer 2 is not enough: missed events from bulk imports and direct database writes, and how the periodic structural sweep closes that gap. --- Reference: [Content Relations](/docs/content-relations) · [MCP tools: Content Relations](/docs/content-relations-mcp) --- ## Content Relations, Layer 1: the typed edge graph - Smeldr URL: https://smeldr.dev/devlog/content-relations-layer-1 Published: 2026-07-14 # Content Relations, Layer 1: the typed edge graph Every content system eventually has to deal with the fact that items reference each other. A blog post cites a research paper. A product page references a specification. A doc page links to an API reference. These connections exist, but are usually invisible. They live as raw strings inside a body field, or as a hard-coded ID in a template, or in nobody's code at all because the developer knew which item pointed where and trusted that knowledge to stay true. Content Relations makes those references explicit. Smeldr now maintains a typed edge graph: a live data structure that the framework actively keeps correct. It records which items depend on which others, what kind of dependency each is, and whether each dependency remains structurally valid. ## The schema Two tables back the graph. `smeldr_relation_kinds` is a registry of named edge categories. `smeldr_relations` is the edge table proper. ```sql CREATE TABLE smeldr_relation_kinds ( id TEXT NOT NULL PRIMARY KEY, type_name TEXT NOT NULL UNIQUE, label TEXT NOT NULL DEFAULT '', mode TEXT NOT NULL, -- "derived" | "asserted" | "inferable" directional INTEGER NOT NULL DEFAULT 1, weighted INTEGER NOT NULL DEFAULT 0, type_pairs TEXT NOT NULL DEFAULT '[]', attributes TEXT NOT NULL DEFAULT '{}', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); CREATE TABLE smeldr_relations ( id TEXT NOT NULL PRIMARY KEY, source_type TEXT NOT NULL, source_id TEXT NOT NULL, target_type TEXT NOT NULL, target_id TEXT NOT NULL, relation_kind TEXT NOT NULL, edge_class TEXT NOT NULL, -- "asserted" | "inferred" confidence REAL, valid_at DATETIME, invalid_at DATETIME, created_by_job TEXT, attributes TEXT NOT NULL DEFAULT '{}', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); ``` A relation kind defines the category of edge: its mode, whether it is directional, and which source/target type pairs are valid. An edge (`RelationEdge`) is a single typed adjacency between two content items. The `edge_class` field distinguishes between edges that an operator or save-path hook asserted (`"asserted"`) and edges that an AI agent proposed for review (`"inferred"`). A `confidence` score and temporal bounds (`valid_at`, `invalid_at`) are optional; the sweep uses `invalid_at` to mark stale edges without deleting them. ## Wiring `RelationStore` wraps the database and an in-memory `RelationKindRegistry` that is hydrated at startup so kind lookups never hit the database: ```go store, err := smeldr.NewRelationStore(db) if err != nil { log.Fatal(err) } app := smeldr.New(cfg). Relations(store) ``` `App.Relations` does three things at once: it stores the `RelationStore`, it installs a `SyncSaveHook` that recomputes asserted edges every time a content item is saved (Layer 1), and it subscribes the cascade handler to `AfterPublish`, `AfterArchive`, `AfterDelete`, and `AfterUnpublish` (Layer 2). The structural sweep (Layer 3) is wired separately via the agent module and covered in the third post in this series. ## Registering a kind and asserting an edge Kinds are registered via `UpsertKind`, which is idempotent and safe to call on every boot: ```go err := store.UpsertKind(ctx, smeldr.RelationKindDef{ TypeName: "cites", Label: "Cites", Mode: "asserted", Directional: true, TypePairs: json.RawMessage(`[ {"source_type": "post", "target_type": "source"} ]`), }) ``` Once the kind is registered, edges can be asserted programmatically: ```go err := store.Assert(ctx, smeldr.RelationEdge{ SourceType: "post", SourceID: postID, TargetType: "source", TargetID: sourceID, RelationKind: "cites", EdgeClass: "asserted", }) ``` Or queried by source, target, or both: ```go edges, err := store.GetBySource(ctx, "post", postID, "cites") dependents, err := store.GetByTarget(ctx, "source", sourceID, "") ``` `GetByTarget` with an empty kind string returns all edges that point at a given item, regardless of relation kind. This is the "who depends on this?" query and the basis for impact preview. ## Layer 1: save-path sync When `App.Relations` is called, a `SyncSaveHook` is installed. After every successful save of a runtime-defined content type, the hook calls `RecomputeAsserted` with the current set of relation fields extracted from the item's data. `RecomputeAsserted` performs a differential update: it reads the existing asserted rows, diffs against the incoming set, and applies only the delta. The diff key is `(target_type, target_id, relation_kind)`. When the diff is empty, the common case, the function returns after one SELECT and zero writes. This is the fastest layer. It runs synchronously on the save path and keeps the graph consistent with no background job required. It only covers runtime-defined content types with a schema field marked `relation: edge`. Compiled content types (via `Module`) can call `store.RecomputeAsserted` directly. ## Node.Rev and optimistic concurrency Content Relations also introduced `Node.Rev`, a monotonically increasing integer on every content node. The storage layer increments it on every save after the first (Rev starts at 0 on insert). If two goroutines read the same node and both attempt to save, the second write returns `ErrRevConflict` rather than silently overwriting: ```go // Rev is incremented by the storage layer on every save after the first. // Use it as an optimistic-concurrency token: if two goroutines read the same // node (both see Rev = 3), the second Save returns ErrRevConflict instead // of silently overwriting. Rev int `db:"rev"` ``` This matters for the relation graph: an edge assertion that races with a delete of the target item is now detectable rather than silent. ## The three-layer model Layer 1 handles the common case: save an item, its relations are updated synchronously. But it cannot react to what happens to the target. If a referenced source item is archived, the save-path hook for the source item will not fire. That is Layer 2's job: reacting to target-side state changes with a cascade signal. And neither Layer 1 nor Layer 2 can catch everything. A bulk import, a direct database write, or a missed signal can leave stale edges in the graph. That is Layer 3's job: the periodic structural sweep. The next two posts in this series cover each of those layers in detail. --- Reference: [Content Relations](/docs/content-relations) --- ## No pipeline, no oil spills - Smeldr URL: https://smeldr.dev/devlog/no-build-pipeline Published: 2026-06-30 # No pipeline, no oil spills At some point in the last decade, shipping a web application started requiring a build step. Then a second one. Then a config file for the bundler, a config file for the CSS processor, a config file for the linter that runs before the bundler, and a CI definition that orchestrates all of them in the right order. Every one of those steps is a dependency. Every dependency has a version. Every version will, eventually, break something. When I started building Smeldr, I made a deliberate choice: no build pipeline. Not "we will add it later." Not "we use a lightweight one." None. The build tool is `go build`. The output is a single binary. That is the whole pipeline. --- ## KISS, taken seriously Keep It Simple is easy to say. It is harder to actually do when the ecosystem keeps offering you tools that each solve a real problem. Webpack solves module bundling. PostCSS solves CSS compatibility. PurgeCSS solves stylesheet size. Each is reasonable on its own. Together they form a chain where a breaking change in one link stops deployment. Simplicity is not about being clever. It is about counting the things that can go wrong and removing as many as you can. A pipeline with five steps has five places to fail. A pipeline with zero steps has zero. The question I kept asking was: does this tool solve a problem I actually have, or a problem the tool introduced by existing? --- ## No vendor lock-in Build pipelines accumulate platform-specific config. A Vercel deployment looks different from a Fly.io deployment. A Netlify function is not a plain HTTP handler. Before long, the pipeline is not just a build tool, it is an implicit contract with a specific platform. A Go binary does not care where it runs. Linux on a VPS, a container on any orchestrator, a bare metal server in a data center. No platform-specific config. No adapter layer. The deployment target is: somewhere that can run a process. That is a decision you can change later without rewriting anything. --- ## Deployment risk The riskiest moment in any software project is deployment. Something that worked locally is moving into production. The fewer differences between those two environments, the lower the risk. A build pipeline is a set of differences. The bundler version on your laptop versus CI versus the version from six months ago when the last deployment happened. The environment variables that PostCSS needs to find its config. The Node version that the build tooling requires, quietly different from the one in production. With `go build`, the binary that runs on your laptop is the binary that runs in production. There is no transformation step that can introduce a discrepancy. You test the artifact, you ship the artifact. --- ## A deferred cost A build pipeline is often justified as a time saver. Configure the bundler once, and development goes faster. That framing is mostly correct. The bundler does save time on the specific problems it was designed to solve. The time it costs shows up later, distributed across every upgrade cycle, every dependency audit, every CI failure that turns out to be a version mismatch between the bundler and a plugin. You are not eliminating the cost. You are deferring it, and adding interest. The pipeline was supposed to give you time back. For some software it does. For a lot of software it turns out to borrow that time from your future self instead. --- ## What it costs There are real trade-offs. No build pipeline means no tree-shaking for JavaScript, no CSS minification by default, no hot module replacement during development. The boundary is not "backend versus frontend." smeldr.dev has frontend JavaScript and still ships without a bundler. The boundary is further out than people assume: it is the point where you are building a large interactive single-page application whose JavaScript genuinely benefits from tree-shaking, code splitting, and hot module replacement. Static assets served over standard HTTP, and CSS served as-is, do not need any of that. The honest version: if you are building a JavaScript-heavy single-page application, you need a bundler and you should have one. The build pipeline exists because it solves real problems for a specific class of software. The question is whether your software is in that class, or whether you acquired a pipeline because that is what software acquires. --- ## go build is the pipeline Smeldr compiles with `go build`. The binary embeds templates and static assets via `embed.FS`. There is no separate asset pipeline, no manifest, no fingerprinting step. The binary is self-contained. This is not a backend-only claim. The site hosting this article runs on Smeldr. It has interactive frontend features and no build pipeline. Two categories of JavaScript, neither of which requires a bundler. The first is a third-party 3D animation: 110k particles, WebGL2, a file too large and complex to write inline. That one is vendored and served as a static file with `