You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(lint): webhook without triggers fails at author time (ADR-0078 Phase 3) (#4565)
A webhook that declares no `triggers` never fires on any path. It still
materializes into `sys_webhook` and still renders in Setup looking armed, so the
omission is invisible on every surface an author can see — the ADR-0078 shape.
## The first source argued AGAINST this rule
The runtime skip site reads:
if (triggers.size === 0) {
// No dispatchable triggers (or a manual-only webhook with none) —
// skip auto-enqueue.
return null;
That parenthetical blesses the empty case as a deliberate mode. It is
structurally identical to the `multiselect`-without-options NON-rule, where
`record-validator.ts`'s `// free-form (tags without options)` is precisely why
we do NOT flag it. On this evidence alone the candidate stays unenforced.
The mode it names does not exist. `webhook.zod.ts`'s #3196 note records that the
`api` (manual/programmatic fire) trigger was REMOVED because "no manual fire
path exists — the only webhook HTTP surface re-queues already-failed
deliveries". Nothing can fire a webhook the auto-enqueuer dropped. Inert on
every path, so: error, not a NON-rule.
The generalization is now in the module doc and pinned by a test: a runtime
comment records what its author BELIEVED, and beliefs go stale when a sibling
feature is deleted. A blessing needs corroboration that the blessed mode is
still reachable, or it is a comment about a mode that no longer exists. The test
asserts the finding carries both citations so nobody demotes the rule on the
comment alone.
`triggers: []` is flagged identically to an omitted `triggers`: unlike an
action's `locations: []` (the documented headless spelling), an empty array here
carries no "I meant it" signal, because a webhook's off switch is `isActive`.
The repo's one real webhook confirms the premise — `showcase_task_changed`
ships inactive via `isActive: false` with a full trigger list, and is clean.
## Phase 3's scope was mostly already closed
Verifying each Tier-B candidate before writing it — the discipline that caught
four false prescriptions in #4001 — found most of the audit's list already
handled or misfiled:
A2 action without `locations` already shipped (validate-action-locations,
which already exempts `locations: []`)
approval empty approvers already shipped (validate-approval-approvers)
select/multiselect options shipped in Phase 1
write-side FK integrity not an authoring-lint item (runtime gap)
`unique` on memory driver not an authoring-lint item (driver gap)
composite sub-field constraints not an authoring-lint item (runtime gap)
nav page/report/url/component genuine gap, but reference resolvability
(ADR-0072), not completeness — wrong module
dataset with zero measures UNVERIFIED, not shipped: no runtime consumer
in this repo
schedule trigger invalid cron UNVERIFIED, not shipped: normalizeSchedule
takes any non-empty string, scheduler
behaviour not traced
webhook without triggers this change
Two candidates are left unshipped rather than written on the audit's stated
confidence, and one is left to the module that owns it.
Verification: 24 predicate tests (19 -> 24) and 8 walk tests (7 -> 8); the one
real webhook in the repo produces no finding; full suite 132/132.
Tracked in #4544.
Claude-Session: https://claude.ai/code/session_01WnqGjQFQMqd5k81LYV8SCY
Co-authored-by: Claude <noreply@anthropic.com>
ADR-0078 Phase 3: a webhook with no `triggers` now fails at author time — and the Tier-B candidate list is corrected to what verification actually supports.
7
+
8
+
**The rule.**`webhook/without-triggers`, error severity, in the shared `@objectstack/spec/kernel` predicate alongside the Phase 1 rules, walked by `@objectstack/lint`'s `validate-functional-completeness` over `stack.webhooks` in both collection spellings. A webhook that declares no trigger materializes into `sys_webhook`, renders in Setup looking armed, and delivers nothing.
9
+
10
+
**Why it needed two sources, and why the first one argued against it.** The runtime skip site reads:
11
+
12
+
```
13
+
if (triggers.size === 0) {
14
+
// No dispatchable triggers (or a manual-only webhook with none) —
15
+
// skip auto-enqueue.
16
+
return null;
17
+
```
18
+
19
+
That parenthetical *blesses* the empty case as a deliberate mode — structurally identical to the `multiselect`-without-options NON-rule, where `record-validator.ts`'s `// free-form (tags without options)` is exactly why we do not flag it. On that evidence alone this candidate stays unenforced.
20
+
21
+
The mode it names does not exist. `webhook.zod.ts`'s #3196 note records that the `api` (manual/programmatic fire) trigger was *removed* because "no manual fire path exists — the only webhook HTTP surface re-queues already-failed deliveries". There is no way to fire a webhook the auto-enqueuer dropped. Inert on every path, so: `error`.
22
+
23
+
> **The generalization, now written into the module and pinned by a test:** a runtime comment records what its author believed, and beliefs go stale when a sibling feature is deleted. A blessing has to be corroborated by something showing the blessed mode is still *reachable* — otherwise it is a comment about a mode that no longer exists. The test asserts the finding carries both citations, so nobody demotes this rule on the strength of the comment alone.
24
+
25
+
`triggers: []` is flagged identically to an omitted `triggers`. Unlike an action's `locations: []` — the documented headless spelling — an empty array here carries no "I meant it" signal, because turning a webhook off has its own key (`isActive`). The repo's one real webhook (`showcase_task_changed`) confirms it: shipped inactive via `isActive: false`, with a full trigger list.
26
+
27
+
**The corrected Tier-B disposition.** Phase 3 was scoped from the 2026-06 audit's Tier-A/B catalog. Verifying each candidate before writing it — the discipline that caught four false prescriptions in #4001 — found most of the list already closed or misfiled:
28
+
29
+
| candidate | disposition |
30
+
|---|---|
31
+
| A2 action without `locations`|**already shipped** — `validate-action-locations.ts`, which already exempts the documented `locations: []`|
32
+
| B approval empty/unresolvable approvers |**already shipped** — `validate-approval-approvers.ts`|
33
+
| B select/multiselect without options | shipped in Phase 1 |
34
+
| B write-side referential integrity |**not an authoring-lint item** — a runtime gap; no metadata omission to detect |
35
+
| B `unique:true` no-op on memory driver |**not an authoring-lint item** — a driver gap |
36
+
| B composite/repeater sub-field constraints |**not an authoring-lint item** — a runtime gap |
37
+
| B nav targets of type page/report/url/component/action |**genuine gap, different module** — the key is present but dangling, which is reference resolvability (ADR-0072), not completeness (ADR-0078) |
38
+
| B dataset with zero measures |**unverified — not shipped.** No runtime consumer in this repo; the dataset compiler lives elsewhere |
39
+
| B webhook without triggers | ✅ **this change**|
40
+
| B schedule trigger with invalid cron |**unverified — not shipped.**`normalizeSchedule` accepts any non-empty string, but the scheduler's behaviour on an invalid one was not traced |
41
+
42
+
Two candidates are deliberately left unshipped rather than written on the audit's stated confidence, and one is left for the module that actually owns it. The audit's own lesson stands: it produces *candidates*, not confirmed bugs — the scariest one collapsed on a three-file read.
0 commit comments