Skip to content

[P2] Webhook: the spec WebhookSchema authoring surface is disconnected from the sys_webhook dispatcher #3461

Description

@os-zhuang

Split out from the naming-drift issue #1891 (webhook row) after a current-state investigation. Umbrella #1878.

What the audit called "naming drift" is actually a full disconnect

#1891 listed webhook as object → object_name / isActive → active naming drift (spec key ≠ consumed key). On investigation it is not a live consumer reading a differently-named key — it's that the entire spec WebhookSchema authoring surface is disconnected from the runtime dispatcher:

  • Spec side — WebhookSchema (packages/spec/src/automation/webhook.zod.ts:81,119) declares object / isActive. It's authored inside a Stack (stack.zod.ts:261 webhooks: z.array(WebhookSchema)) and by connectors (connector.zod.ts:271 WebhookConfigSchema). It is not a registered metadata type (absent from kernel/metadata-type-schemas.ts).
  • Runtime side — the dispatcher runs off the sys_webhook data object (plugins/plugin-webhooks/src/sys-webhook.object.ts), whose columns are object_name (:112) and active (:53). AutoEnqueuer reads row.object_name (auto-enqueuer.ts:267) and where: { active: true } (:176). These rows are admin-authored via the object's CRUD UI (sys-webhook.object.ts:43-44 — "so admins can at least toggle active and edit simple URL/method fields without round-tripping through code"), and self-healed on sys_webhook:changed.
  • The gap — there is no ingestion path that turns a stack/connector WebhookSchema (object/isActive) into a sys_webhook row (object_name/active). Connector webhooks are explicitly "not yet enforced — never read at registration" (connector.zod.ts:662, Audit: several event/subscription/connector enums are schema-only (declared, no runtime consumer) #3197). So authoring webhooks: on a stack produces webhook metadata artifacts that nothing materializes into dispatchable rows.

Consequence: a parse-time object → object_name alias on WebhookSchema (the "quick fix") would be inert — there is no consumer of the spec field to bridge to. Shipping it would be a false fix (ADR-0078). That's why this is split out rather than closed under #1891.

Decision needed (pick one)

  • A — build the bridge. Ingest stack/connector WebhookSchema into sys_webhook rows, mapping object → object_name, isActive → active, triggers/url/method/secret/… at that boundary. Closes the disconnect and the naming drift in one place. Overlaps Audit: several event/subscription/connector enums are schema-only (declared, no runtime consumer) #3197 (connector webhooks never read at registration). Larger; needs a home for the seeder (metadata ingestion or the webhooks plugin).
  • B — retire the spec authoring surface. Treat sys_webhook (admin-authored rows) as the single source of truth; demote/remove stack.webhooks + WebhookSchema (breaking — public export; needs a major + migration note), or mark it clearly [EXPERIMENTAL — not enforced / author sys_webhook rows instead] in the interim.

Recommendation: A if declarative/stack-authored webhooks are on the roadmap (it's the ADR-0078-correct end state); otherwise B's interim marking so authoring webhooks: isn't a silent no-op.

Related

Activity

  1. self-assigned this
    on Jul 25, 2026
  2. os-zhuang commented on Jul 25, 2026

    @os-zhuang
    ContributorAuthor

    Liveness enrollment landed (#3485, 189854c) — this does not close the decision here. The systemic follow-up #3462 asked for webhook to enter the liveness GOVERNED set; that's done, and it's deliberately neutral on this issue's A-vs-B choice.

    What the enrollment locks in, and the evidence it captured while re-confirming the disconnect at HEAD:

    • The disconnect is confirmed, not just naming drift. The runtime AutoEnqueuer reads only sys_webhook data rows (plugins/plugin-webhooks/src/auto-enqueuer.ts:175, where: { active: true }); there is no seeder and zero insert('sys_webhook') anywhere. Authored webhooks: never becomes a dispatchable row.
    • The dead-end registration trap. objectql does "ingest" webhooks: — engine.ts:1183/:1343 register each as a generic in-memory metadata item under type 'webhook' — but nothing reads those items back, and they are not the sys_webhook table. This is why the surface looks connected on a shallow read; the ledger _note calls it out so it isn't mistaken for a working bridge.
    • Stale comments worth fixing when A/B is decided. sys-webhook.object.ts:46 ("Authored via defineWebhook() in code or the Studio editor") is aspirational for the code half — there's no path from a defineWebhook() artifact to a row. And sys-webhook.object.ts:13-18 attributes the loader to service-automation's http_request node; the real consumer is plugin-webhooks' AutoEnqueuer.
    • The mapping table is pre-written. packages/spec/liveness/webhook.json classifies all 16 authorable props dead (+ authentication experimental), and each note records the salvageable sys_webhook column (object→object_name, isActive→active, definition_json-only for headers/secret/timeoutMs) vs the no-sink-anywhere props (body/payloadFields/includeSession/retryPolicy/tags). Option A's materializer already has its field map here.
    • Interim author protection is live now. os compile warns that webhooks: is a silent no-op (one warning per webhook) — the same heads-up option B proposed as an interim marker, but without touching the schema, so it's neutral on the outcome.

    When A lands, flip the mapped props to live (cite the materializer) and register webhook as a real metadata type; when B lands, the ledger is removed with the schema. Leaving this open to track that.

  3. os-zhuang commented on Jul 25, 2026

    @os-zhuang
    ContributorAuthor

    Resolved via Option A (build the bridge) in #3489 — Option B would retire a public authoring surface the showcase already uses, and the registry decomposition side already exists so A was the smaller build than it first appeared.

    What the fix does

    • bootstrapDeclaredWebhooks (in @objectstack/plugin-webhooks) reads declared webhook metadata from the ObjectQL registry — where the manifest decomposition already parks stack.webhooks as type webhook (engine.ts metadataArrayKeys) — validates each through WebhookSchema.parse() (the spec schema now has a real consumer), and materializes a sys_webhook row at boot: object → object_name, isActive → active, full envelope → definition_json. Modeled on the sibling bootstrapDeclaredSharingRules.
    • Materialization runs on the data engine alone, before the auto-enqueuer's first refresh — intentionally not gated behind the realtime/messaging dispatch prerequisites, so a realtime-less deployment can't reproduce the silent no-op.
    • Seed-not-clobber provenance (mirrors sys_sharing_rule Audit sibling declared-metadata↔record two-store types (sys_position, sys_sharing_rule, sys_capability) per ADR-0094 addendum #2909): sys_webhook gains managed_by/customized; declared webhooks re-seed as package, but admin-created/edited rows are never clobbered.

    Also surfaced during the fix: the showcase authored webhooks: but never required the webhooks capability, so WebhookOutboxPlugin wasn't even mounted — a second layer of the same disconnect. The showcase now requires webhooks + realtime, and ships the demo webhook inactive (placeholder endpoint).

    Verified: 9 unit tests (red-proofed) + a real objectstack dev boot — declared webhook materializes into a sys_webhook row, same-DB reboot is idempotent, and an admin's customized edit survives redeploy.

    Deliberately out of scope (tracked separately):

  4. added a commit that references this issue on Aug 3, 2026
  5. added a commit that references this issue on Sep 28, 2026
    351a161
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions