gastownhall/beads · error

httpapi: a configured role fires this workspace's hooks; thi

Error message

httpapi: a configured role fires this workspace's hooks; this server does not run hooks, so take the roles from the store beneath the hook decorator ((*storage.HookFiringStore).Unwrap)

What it means

This startup-time validation error means one or more of the issue roles (Reader, Claimer, etc.) configured on httpapi.Server come from a store that fires the workspace's on_update hooks. The HTTP server's contract is that it never runs hooks — otherwise every served mutation would spawn the user's hook subprocess. Rather than silently breaking that contract, the server refuses to start and points at (*storage.HookFiringStore).Unwrap, which hands back the store beneath the hook decorator.

Source

Thrown at internal/httpapi/server.go:665

}

func checkDatabaseSource(cfg Config) error {
	switch {
	case cfg.Provider != nil && (anyRoleSet(cfg) || cfg.EventsJournal != nil):
		return errors.New("httpapi: both a unit-of-work provider and issue roles were set; pass exactly one database source")
	case cfg.Provider == nil && !everyRoleSet(cfg):
		return errors.New("httpapi: no database source: set Provider, or " + roleSourceNames + " together")
	// The conditional role, checked where every other configuration mistake is.
	// A workspace that HAS a journal and a server that cannot read it is the
	// one combination that would bind, answer every other route, and fail this
	// one — with a nil dereference, which is the shape checkDatabaseSource
	// exists to prevent. A workspace with the journal off needs no reader and
	// this says nothing about it.
	case cfg.Provider == nil && cfg.EventsJournalEnabled && cfg.EventsJournal == nil:
		return errors.New("httpapi: this workspace's events journal is enabled but no EventsJournal reader was configured; " +
			"take one off the store (storage.EventsJournalCursor), or serve a workspace with the journal off")
	case anyRoleFiresHooks(cfg):
		return errors.New("httpapi: a configured role fires this workspace's hooks; " +
			"this server does not run hooks, so take the roles from the store beneath the hook decorator " +
			"((*storage.HookFiringStore).Unwrap)")
	case uow.ProviderFiresHooks(cfg.Provider):
		// The same refusal for the other database source. A provider's roles
		// carry whatever the provider carries, so a hook-firing one would run a
		// user's subprocess per served mutation just as a hook-firing role does.
		return errors.New("httpapi: the configured provider fires this workspace's hooks; " +
			"this server does not run hooks, so pass the provider beneath the hook layer " +
			"(uow.UnwrapProvider)")
	}
	return nil
}

// Addr is the bound address, which is the only way to discover the port under
// the ephemeral default.
func (s *Server) Addr() string { return s.listener.Addr().String() }

// Serve accepts requests until ctx is canceled, then drains. It returns nil

View on GitHub (pinned to 71377f2769)

Solutions

  1. Wrap the store's accessors with (*storage.HookFiringStore).Unwrap to get the store beneath the hook decorator before assigning roles to the server Config
  2. Verify with storage.RoleFiresHooks that none of the roles you pass fire hooks
  3. If hooks should actually fire on these mutations, do not use this server's contract — run hooks in your own layer instead

Example fix

// before
srv, err := httpapi.New(httpapi.Config{Reader: store.IssueReader(), Claimer: store.IssueClaimer()})
// after
unwrapped := store.(*storage.HookFiringStore).Unwrap()
srv, err := httpapi.New(httpapi.Config{Reader: unwrapped.IssueReader(), Claimer: unwrapped.IssueClaimer()})
Defensive patterns

Strategy: validation

Validate before calling

for _, r := range []any{cfg.Reader, cfg.Claimer, cfg.ReadyClaimer, cfg.Releaser} {
    if r != nil && storage.RoleFiresHooks(r) {
        return errors.New("role fires hooks; unwrap the HookFiringStore first")
    }
}

Type guard

func roleSafe(r any) bool { return r == nil || !storage.RoleFiresHooks(r) }

Try / catch

if err := checkDatabaseSource(cfg); err != nil {
    if strings.Contains(err.Error(), "fires this workspace's hooks") {
        cfg = unwrapHookRoles(cfg) // (*storage.HookFiringStore).Unwrap on the source store
    }
    return err
}

Prevention

When it happens

Trigger: Calling httpapi.New (or equivalent) with Config roles obtained directly from a storage.HookFiringStore, e.g. store.IssueClaimer() on a store wrapped in the hook decorator. checkDatabaseSource detects any role where storage.RoleFiresHooks is true at Listen/startup and returns this error.

Common situations: A developer wires bd serve with the most natural code — roles taken straight off the storage chain — without realizing the accessor on a hook-wrapped store returns hook-firing decorators. Happens after adopting hooks in a workspace, or when copying server setup from code that intentionally fires hooks.

Related errors


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/728e0add3da2edbc. Report an issue: GitHub.