{"record":{"id":"728e0add3da2edbc","repo":"gastownhall/beads","slug":"httpapi-a-configured-role-fires-this-workspace-s","errorCode":null,"errorMessage":"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)","messagePattern":"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\\)","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/httpapi/server.go","lineNumber":665,"sourceCode":"}\n\nfunc checkDatabaseSource(cfg Config) error {\n\tswitch {\n\tcase cfg.Provider != nil && (anyRoleSet(cfg) || cfg.EventsJournal != nil):\n\t\treturn errors.New(\"httpapi: both a unit-of-work provider and issue roles were set; pass exactly one database source\")\n\tcase cfg.Provider == nil && !everyRoleSet(cfg):\n\t\treturn errors.New(\"httpapi: no database source: set Provider, or \" + roleSourceNames + \" together\")\n\t// The conditional role, checked where every other configuration mistake is.\n\t// A workspace that HAS a journal and a server that cannot read it is the\n\t// one combination that would bind, answer every other route, and fail this\n\t// one — with a nil dereference, which is the shape checkDatabaseSource\n\t// exists to prevent. A workspace with the journal off needs no reader and\n\t// this says nothing about it.\n\tcase cfg.Provider == nil && cfg.EventsJournalEnabled && cfg.EventsJournal == nil:\n\t\treturn errors.New(\"httpapi: this workspace's events journal is enabled but no EventsJournal reader was configured; \" +\n\t\t\t\"take one off the store (storage.EventsJournalCursor), or serve a workspace with the journal off\")\n\tcase anyRoleFiresHooks(cfg):\n\t\treturn errors.New(\"httpapi: a configured role fires this workspace's hooks; \" +\n\t\t\t\"this server does not run hooks, so take the roles from the store beneath the hook decorator \" +\n\t\t\t\"((*storage.HookFiringStore).Unwrap)\")\n\tcase uow.ProviderFiresHooks(cfg.Provider):\n\t\t// The same refusal for the other database source. A provider's roles\n\t\t// carry whatever the provider carries, so a hook-firing one would run a\n\t\t// user's subprocess per served mutation just as a hook-firing role does.\n\t\treturn errors.New(\"httpapi: the configured provider fires this workspace's hooks; \" +\n\t\t\t\"this server does not run hooks, so pass the provider beneath the hook layer \" +\n\t\t\t\"(uow.UnwrapProvider)\")\n\t}\n\treturn nil\n}\n\n// Addr is the bound address, which is the only way to discover the port under\n// the ephemeral default.\nfunc (s *Server) Addr() string { return s.listener.Addr().String() }\n\n// Serve accepts requests until ctx is canceled, then drains. It returns nil","sourceCodeStart":647,"sourceCodeEnd":683,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/httpapi/server.go#L647-L683","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Wrap the store's accessors with (*storage.HookFiringStore).Unwrap to get the store beneath the hook decorator before assigning roles to the server Config","Verify with storage.RoleFiresHooks that none of the roles you pass fire hooks","If hooks should actually fire on these mutations, do not use this server's contract — run hooks in your own layer instead"],"exampleFix":"// before\nsrv, err := httpapi.New(httpapi.Config{Reader: store.IssueReader(), Claimer: store.IssueClaimer()})\n// after\nunwrapped := store.(*storage.HookFiringStore).Unwrap()\nsrv, err := httpapi.New(httpapi.Config{Reader: unwrapped.IssueReader(), Claimer: unwrapped.IssueClaimer()})","handlingStrategy":"validation","validationCode":"for _, r := range []any{cfg.Reader, cfg.Claimer, cfg.ReadyClaimer, cfg.Releaser} {\n    if r != nil && storage.RoleFiresHooks(r) {\n        return errors.New(\"role fires hooks; unwrap the HookFiringStore first\")\n    }\n}","typeGuard":"func roleSafe(r any) bool { return r == nil || !storage.RoleFiresHooks(r) }","tryCatchPattern":"if err := checkDatabaseSource(cfg); err != nil {\n    if strings.Contains(err.Error(), \"fires this workspace's hooks\") {\n        cfg = unwrapHookRoles(cfg) // (*storage.HookFiringStore).Unwrap on the source store\n    }\n    return err\n}","preventionTips":["Always derive server roles from the unwrapped store, never from the hook-decorated one","Add a startup assertion that storage.RoleFiresHooks is false for every configured role","Centralize server store wiring in one constructor so unwrapping cannot be forgotten"],"tags":["httpapi","hooks","configuration","startup-validation"],"backgroundTag":"hook-firing-store-misconfigured","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}