@celilo/cli 0.13.3 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CELILO_CORE_MODULES.md +3 -0
  2. package/CELILO_SUBSYSTEMS.md +71 -2
  3. package/docs/ALERTING.md +298 -0
  4. package/docs/INDEX.md +103 -0
  5. package/drizzle/0016_trusted_sources.sql +10 -0
  6. package/drizzle/0017_alerting.sql +127 -0
  7. package/drizzle/meta/_journal.json +15 -1
  8. package/package.json +3 -2
  9. package/schemas/system_config.json +9 -0
  10. package/src/capabilities/well-known.ts +11 -1
  11. package/src/cli/commands/alerts-act.ts +107 -0
  12. package/src/cli/commands/alerts-list.ts +62 -0
  13. package/src/cli/commands/alerts-poll.ts +129 -0
  14. package/src/cli/commands/alerts-sweep.ts +156 -0
  15. package/src/cli/commands/module-list.ts +50 -3
  16. package/src/cli/commands/module-show.ts +11 -3
  17. package/src/cli/commands/monitor.ts +178 -0
  18. package/src/cli/commands/notify-config.ts +453 -0
  19. package/src/cli/commands/system-audit.ts +2 -0
  20. package/src/cli/commands/system-update.ts +1 -0
  21. package/src/cli/completion.ts +26 -0
  22. package/src/cli/generate-zsh-completion.ts +2 -0
  23. package/src/cli/index.ts +58 -0
  24. package/src/cli/tui/audit-state.ts +2 -0
  25. package/src/db/schema.ts +371 -2
  26. package/src/hooks/capability-loader.ts +158 -46
  27. package/src/hooks/capability-map-coverage.test.ts +101 -0
  28. package/src/manifest/schema.ts +77 -4
  29. package/src/services/alerting/ack.test.ts +212 -0
  30. package/src/services/alerting/ack.ts +119 -0
  31. package/src/services/alerting/builtin-monitors.test.ts +132 -0
  32. package/src/services/alerting/builtin-monitors.ts +84 -0
  33. package/src/services/alerting/builtin-source.ts +82 -0
  34. package/src/services/alerting/coverage-source.ts +38 -0
  35. package/src/services/alerting/deferral.test.ts +161 -0
  36. package/src/services/alerting/delivery-loop.test.ts +396 -0
  37. package/src/services/alerting/deploy-hooks.test.ts +125 -0
  38. package/src/services/alerting/deploy-hooks.ts +111 -0
  39. package/src/services/alerting/escalation.test.ts +207 -0
  40. package/src/services/alerting/escalation.ts +151 -0
  41. package/src/services/alerting/format.test.ts +193 -0
  42. package/src/services/alerting/format.ts +150 -0
  43. package/src/services/alerting/health-coverage.ts +81 -0
  44. package/src/services/alerting/inbound-poller.test.ts +298 -0
  45. package/src/services/alerting/inbound-poller.ts +236 -0
  46. package/src/services/alerting/inbound.test.ts +201 -0
  47. package/src/services/alerting/inbound.ts +112 -0
  48. package/src/services/alerting/interview-responder.test.ts +169 -0
  49. package/src/services/alerting/interview-responder.ts +158 -0
  50. package/src/services/alerting/keys.test.ts +155 -0
  51. package/src/services/alerting/keys.ts +190 -0
  52. package/src/services/alerting/monitors.ts +185 -0
  53. package/src/services/alerting/notification-responder.test.ts +290 -0
  54. package/src/services/alerting/notification-responder.ts +260 -0
  55. package/src/services/alerting/notifier.ts +219 -0
  56. package/src/services/alerting/people.ts +178 -0
  57. package/src/services/alerting/quiet-hours.test.ts +140 -0
  58. package/src/services/alerting/quiet-hours.ts +99 -0
  59. package/src/services/alerting/reconcile.test.ts +190 -0
  60. package/src/services/alerting/reconcile.ts +166 -0
  61. package/src/services/alerting/run-monitor.test.ts +185 -0
  62. package/src/services/alerting/run-monitor.ts +177 -0
  63. package/src/services/alerting/store.test.ts +222 -0
  64. package/src/services/alerting/store.ts +289 -0
  65. package/src/services/alerting/suppression.test.ts +228 -0
  66. package/src/services/alerting/suppression.ts +142 -0
  67. package/src/services/alerting/sweep-runner.test.ts +229 -0
  68. package/src/services/alerting/sweep-runner.ts +204 -0
  69. package/src/services/alerting/sweep.test.ts +61 -0
  70. package/src/services/alerting/sweep.ts +41 -0
  71. package/src/services/alerting/tokens.test.ts +152 -0
  72. package/src/services/alerting/tokens.ts +119 -0
  73. package/src/services/alerting/transport-loader.ts +48 -0
  74. package/src/services/audit/index.test.ts +1 -0
  75. package/src/services/audit/index.ts +3 -0
  76. package/src/services/audit/trusted-sources.test.ts +137 -0
  77. package/src/services/audit/trusted-sources.ts +124 -0
  78. package/src/services/audit/types.ts +2 -1
  79. package/src/services/firewall-reach.ts +83 -0
  80. package/src/services/health-runner.test.ts +50 -0
  81. package/src/services/health-runner.ts +116 -82
  82. package/src/services/machine-pool.ts +2 -1
  83. package/src/services/module-deploy.ts +17 -0
  84. package/src/services/system-config-validator.test.ts +31 -1
  85. package/src/services/trusted-sources.test.ts +221 -0
  86. package/src/services/trusted-sources.ts +159 -0
  87. package/src/services/update/orchestrator.test.ts +1 -0
  88. package/src/templates/generator.ts +6 -29
package/src/db/schema.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { sql } from 'drizzle-orm';
2
2
  import {
3
+ index,
3
4
  integer,
4
5
  primaryKey,
5
6
  sqliteTable,
@@ -197,7 +198,7 @@ export const ipAllocations = sqliteTable('ip_allocations', {
197
198
  .references(() => modules.id, { onDelete: 'cascade' }),
198
199
  vmid: integer('vmid').notNull().unique(),
199
200
  containerIp: text('container_ip').notNull().unique(), // CIDR format (e.g., "10.0.10.10/24")
200
- zone: text('zone').$type<'dmz' | 'app' | 'secure' | 'secure-mgmt' | 'internal'>().notNull(),
201
+ zone: text('zone').$type<AllocatableZone>().notNull(),
201
202
  allocatedAt: integer('allocated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
202
203
  });
203
204
 
@@ -210,7 +211,7 @@ export const ipReservations = sqliteTable('ip_reservations', {
210
211
  id: integer('id').primaryKey({ autoIncrement: true }),
211
212
  ipStart: text('ip_start').notNull(), // Single IP or range start
212
213
  ipEnd: text('ip_end'), // NULL for single IP, end IP for range
213
- zone: text('zone').$type<'dmz' | 'app' | 'secure' | 'secure-mgmt' | 'internal'>().notNull(),
214
+ zone: text('zone').$type<AllocatableZone>().notNull(),
214
215
  reason: text('reason').notNull(),
215
216
  reservedAt: integer('reserved_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
216
217
  });
@@ -274,6 +275,17 @@ export const NETWORK_ZONES = [
274
275
  */
275
276
  export type NetworkZone = (typeof NETWORK_ZONES)[number];
276
277
 
278
+ /**
279
+ * Zones an IP allocation or reservation can name: every NetworkZone except
280
+ * `external`, whose systems are addressed by the provider, not by our IPAM.
281
+ *
282
+ * Derived rather than hand-written for the same reason as NetworkZone above —
283
+ * the previous hand-written union was copied into two column definitions and a
284
+ * cast in machine-pool.ts, and the cast had already drifted (it was missing
285
+ * `secure-mgmt`, and its comment claimed the only difference was `external`).
286
+ */
287
+ export type AllocatableZone = Exclude<NetworkZone, 'external'>;
288
+
277
289
  /**
278
290
  * Container services table
279
291
  * Stores container service providers (Proxmox, Digital Ocean, etc.)
@@ -503,6 +515,39 @@ export const portForwards = sqliteTable(
503
515
  }),
504
516
  );
505
517
 
518
+ /**
519
+ * Trusted-source registry — the desired-state store for "this subnet may reach
520
+ * every managed zone", the sibling of `port_forwards` and for the same reason: a
521
+ * rule that lives only in the applied ruleset belongs to no module, so the next
522
+ * converge correctly removes it. The admin VPN sat in exactly that position and
523
+ * needed a shell script re-adding its rules every second.
524
+ *
525
+ * Distinct from a port forward: that publishes one backend on specific ports;
526
+ * this is a whole origin subnet permitted to initiate into the segmented tiers.
527
+ */
528
+ export const trustedSources = sqliteTable(
529
+ 'trusted_sources',
530
+ {
531
+ id: integer('id').primaryKey({ autoIncrement: true }),
532
+ /** The firewall host that renders this trust (config.firewallIp). */
533
+ firewallIp: text('firewall_ip').notNull(),
534
+ /** Subnet CIDR permitted to reach every managed zone. */
535
+ subnet: text('subnet').notNull(),
536
+ description: text('description').notNull().default(''),
537
+ /** Module that registered it — reach into every tier must never be anonymous. */
538
+ registeredBy: text('registered_by').notNull(),
539
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
540
+ },
541
+ (table) => ({
542
+ // One row per (firewall, subnet) — the store delete-then-inserts on this
543
+ // tuple so re-registering the same subnet is an idempotent upsert.
544
+ trustedSourceUnique: uniqueIndex('trusted_sources_unique_idx').on(
545
+ table.firewallIp,
546
+ table.subnet,
547
+ ),
548
+ }),
549
+ );
550
+
506
551
  /**
507
552
  * DNS registration ledger — one row per (provider, fqdn) the framework
508
553
  * has successfully registered via dns_registrar.registerHost. Written
@@ -742,6 +787,312 @@ export const apiPrincipals = sqliteTable('api_principals', {
742
787
  updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
743
788
  });
744
789
 
790
+ // ---------------------------------------------------------------------------
791
+ // Alerting (openspec/changes/add-alerting)
792
+ // ---------------------------------------------------------------------------
793
+
794
+ /**
795
+ * Alert severity. Only `critical` pages.
796
+ *
797
+ * A check item's `warn` status always yields `warning`; a `fail` yields the
798
+ * monitor's configured severity. A monitor deliberately configured as
799
+ * `warning` therefore never pages even when its checks fail — the operator's
800
+ * "record this but don't wake me" knob. See design D6.
801
+ */
802
+ export type AlertSeverity = 'warning' | 'critical';
803
+
804
+ /** What a monitor runs. `module_hook` = a module's health_check hook. */
805
+ export type MonitorKind = 'module_hook' | 'builtin_check';
806
+
807
+ /**
808
+ * Whether a monitor run executed at all. The load-bearing distinction: a run
809
+ * that could not execute produces an empty failing set for reasons that say
810
+ * nothing about the underlying checks, so it must never resolve anything.
811
+ * See design D5.
812
+ */
813
+ export type MonitorRunOutcome = 'success' | 'error';
814
+
815
+ export type AlertState = 'pending' | 'firing' | 'acked' | 'suppressed' | 'resolved';
816
+
817
+ /**
818
+ * People celilo can reach. Deliberately independent of any transport or
819
+ * module — a person exists before any notification module is deployed.
820
+ */
821
+ export const people = sqliteTable('people', {
822
+ id: text('id').primaryKey(), // UUID
823
+ /** User-facing kebab-case name (e.g. "peter"). Never a UUID in CLI output. */
824
+ name: text('name').notNull().unique(),
825
+ /** IANA timezone (e.g. "America/Los_Angeles") — quiet hours are local to it. */
826
+ timezone: text('timezone').notNull(),
827
+ /** Quiet-hours window as local "HH:MM"; both null = always reachable. */
828
+ quietHoursStart: text('quiet_hours_start'),
829
+ quietHoursEnd: text('quiet_hours_end'),
830
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
831
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
832
+ });
833
+
834
+ /**
835
+ * A person's address on a transport, plus the policy for using it.
836
+ *
837
+ * Addresses live HERE, never in the transport module's config — otherwise
838
+ * adding a recipient would require redeploying the module. signal-cli holds
839
+ * one credential (its own registration); recipients are addresses, not
840
+ * credentials. See design D8.
841
+ */
842
+ export const routes = sqliteTable(
843
+ 'routes',
844
+ {
845
+ id: text('id').primaryKey(), // UUID
846
+ personId: text('person_id')
847
+ .notNull()
848
+ .references(() => people.id, { onDelete: 'cascade' }),
849
+ /** Module providing the `notification` capability. */
850
+ transportModuleId: text('transport_module_id')
851
+ .notNull()
852
+ .references(() => modules.id, { onDelete: 'cascade' }),
853
+ /** Transport-specific address (phone number, email, topic). */
854
+ address: text('address').notNull(),
855
+ /** Alerts below this severity are not delivered here. */
856
+ severityFloor: text('severity_floor').$type<AlertSeverity>().notNull().default('warning'),
857
+ /**
858
+ * Whether replies can arrive over this route — set from whether the
859
+ * transport implements `receive`. A route that cannot ack never stops
860
+ * escalation (spec: *Unidirectional transport cannot acknowledge*).
861
+ */
862
+ canAck: integer('can_ack', { mode: 'boolean' }).notNull().default(false),
863
+ enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
864
+ verifiedAt: integer('verified_at', { mode: 'timestamp' }),
865
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
866
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
867
+ },
868
+ (table) => ({
869
+ uniqueAddress: unique().on(table.personId, table.transportModuleId, table.address),
870
+ }),
871
+ );
872
+
873
+ /**
874
+ * Named, reusable escalation policy. Steps reference ROUTES rather than
875
+ * people — "page Peter" is ambiguous about which transport to use.
876
+ */
877
+ export const escalationPolicies = sqliteTable('escalation_policies', {
878
+ id: text('id').primaryKey(), // UUID
879
+ /** User-facing kebab-case name (e.g. "default", "critical"). */
880
+ name: text('name').notNull().unique(),
881
+ /**
882
+ * Reserved escape hatch (design D13): quiet hours otherwise defer ALL
883
+ * severities. Unused in MVP — the column exists so enabling it later is not
884
+ * a migration.
885
+ */
886
+ bypassQuietHours: integer('bypass_quiet_hours', { mode: 'boolean' }).notNull().default(false),
887
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
888
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
889
+ });
890
+
891
+ /** One ordered step of an escalation policy. Delay is from escalation start. */
892
+ export const escalationSteps = sqliteTable(
893
+ 'escalation_steps',
894
+ {
895
+ policyId: text('policy_id')
896
+ .notNull()
897
+ .references(() => escalationPolicies.id, { onDelete: 'cascade' }),
898
+ /** 0-based order within the policy. */
899
+ stepIndex: integer('step_index').notNull(),
900
+ routeId: text('route_id')
901
+ .notNull()
902
+ .references(() => routes.id, { onDelete: 'cascade' }),
903
+ /** Minutes after escalation begins. Step 0 is normally 0. */
904
+ delayMinutes: integer('delay_minutes').notNull(),
905
+ },
906
+ (table) => ({
907
+ pk: primaryKey({ columns: [table.policyId, table.stepIndex] }),
908
+ }),
909
+ );
910
+
911
+ /**
912
+ * What gets checked, how often, and how failures are routed.
913
+ *
914
+ * The module manifest's `hooks.health_check.interval` is only a SUGGESTION;
915
+ * this row is the effective schedule and an operator edit survives module
916
+ * upgrades. See design D3.
917
+ */
918
+ export const monitors = sqliteTable(
919
+ 'monitors',
920
+ {
921
+ id: text('id').primaryKey(), // UUID
922
+ kind: text('kind').$type<MonitorKind>().notNull(),
923
+ /** Module id for `module_hook`; audit check name for `builtin_check`. */
924
+ target: text('target').notNull(),
925
+ intervalMinutes: integer('interval_minutes').notNull(),
926
+ /** Severity applied to `fail` items. Only `critical` pages. */
927
+ severity: text('severity').$type<AlertSeverity>().notNull().default('critical'),
928
+ /**
929
+ * False for monitors watching the alerting system itself — a cascading
930
+ * failure must not silence the component reporting it (design S4).
931
+ */
932
+ suppressible: integer('suppressible', { mode: 'boolean' }).notNull().default(true),
933
+ enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
934
+ escalationPolicyId: text('escalation_policy_id').references(() => escalationPolicies.id, {
935
+ onDelete: 'set null',
936
+ }),
937
+ lastRunAt: integer('last_run_at', { mode: 'timestamp' }),
938
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
939
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
940
+ },
941
+ (table) => ({
942
+ uniqueTarget: unique().on(table.kind, table.target),
943
+ }),
944
+ );
945
+
946
+ /**
947
+ * Record of each monitor execution. Exists so reconciliation can branch on
948
+ * whether the last run actually ran (design D5) — without a persisted
949
+ * outcome there is nothing to distinguish "found nothing wrong" from
950
+ * "couldn't look".
951
+ */
952
+ export const monitorRuns = sqliteTable('monitor_runs', {
953
+ id: integer('id').primaryKey({ autoIncrement: true }),
954
+ monitorId: text('monitor_id')
955
+ .notNull()
956
+ .references(() => monitors.id, { onDelete: 'cascade' }),
957
+ ranAt: integer('ran_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
958
+ outcome: text('outcome').$type<MonitorRunOutcome>().notNull(),
959
+ /** Populated when outcome is `error` (ssh failure, timeout, hook threw). */
960
+ errorMessage: text('error_message'),
961
+ });
962
+
963
+ /**
964
+ * A deliberate, time-boxed suppression source. Today only deploys create
965
+ * these — a deploy is the same suppression mechanism as a machine-down alert,
966
+ * with a window as the source instead of an ancestor alert (design D7).
967
+ */
968
+ export const suppressionWindows = sqliteTable('suppression_windows', {
969
+ id: text('id').primaryKey(), // UUID
970
+ source: text('source').$type<'deploy'>().notNull(),
971
+ scopeModuleId: text('scope_module_id')
972
+ .notNull()
973
+ .references(() => modules.id, { onDelete: 'cascade' }),
974
+ startedAt: integer('started_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
975
+ /** Null while open. Set on deploy completion, including on failure. */
976
+ endsAt: integer('ends_at', { mode: 'timestamp' }),
977
+ });
978
+
979
+ /**
980
+ * The alert lifecycle. One row per alert key per firing episode: a resolve
981
+ * followed by a re-fire mints a new row, so history is preserved.
982
+ *
983
+ * `acked`, `suppressed`, and `silenced` are deliberately THREE separate
984
+ * concerns (design S5) — collapsing any two is how alerting systems become
985
+ * untrustworthy.
986
+ */
987
+ export const alerts = sqliteTable(
988
+ 'alerts',
989
+ {
990
+ id: text('id').primaryKey(), // UUID
991
+ /** Stable key, e.g. `module:caddy/check:cert-validity`. See design D4. */
992
+ key: text('key').notNull(),
993
+ /**
994
+ * Mirrors `key` while the alert is live; set to NULL on resolve. Backs the
995
+ * "one live alert per key" unique index below — SQLite treats NULLs in a
996
+ * unique index as distinct, so any number of resolved rows may share a key
997
+ * while at most one live row may hold it. Written only by the
998
+ * reconciliation code that sets `state`; the two must move together.
999
+ */
1000
+ activeKey: text('active_key'),
1001
+ monitorId: text('monitor_id')
1002
+ .notNull()
1003
+ .references(() => monitors.id, { onDelete: 'cascade' }),
1004
+ state: text('state').$type<AlertState>().notNull().default('pending'),
1005
+ severity: text('severity').$type<AlertSeverity>().notNull(),
1006
+ firstFiredAt: integer('first_fired_at', { mode: 'timestamp' })
1007
+ .notNull()
1008
+ .default(sql`(unixepoch())`),
1009
+ lastSeenAt: integer('last_seen_at', { mode: 'timestamp' })
1010
+ .notNull()
1011
+ .default(sql`(unixepoch())`),
1012
+ /**
1013
+ * Notification is withheld until this instant, so an ancestor firing
1014
+ * moments later can establish suppression first, and so a failure that
1015
+ * clears immediately never pages (design S1).
1016
+ */
1017
+ graceUntil: integer('grace_until', { mode: 'timestamp' }).notNull(),
1018
+ /** Set when an ancestor ALERT is suppressing this one. */
1019
+ suppressedByAlertId: text('suppressed_by_alert_id'),
1020
+ /** Set when a suppression WINDOW (e.g. a deploy) is suppressing this one. */
1021
+ suppressedByWindowId: text('suppressed_by_window_id').references(() => suppressionWindows.id, {
1022
+ onDelete: 'set null',
1023
+ }),
1024
+ /** Escalation clock origin after suppression lifts — NOT firstFiredAt (S3). */
1025
+ unsuppressedAt: integer('unsuppressed_at', { mode: 'timestamp' }),
1026
+ /**
1027
+ * True between un-suppression and the next SUCCESSFUL run. While set, the
1028
+ * alert does not notify: if it recovered along with its ancestor it
1029
+ * resolves quietly instead of paging (design S2).
1030
+ */
1031
+ awaitingConfirmation: integer('awaiting_confirmation', { mode: 'boolean' })
1032
+ .notNull()
1033
+ .default(false),
1034
+ ackedBy: text('acked_by').references(() => people.id, { onDelete: 'set null' }),
1035
+ ackedAt: integer('acked_at', { mode: 'timestamp' }),
1036
+ /** Deliberate operator silence — distinct from suppression (S5). */
1037
+ silencedUntil: integer('silenced_until', { mode: 'timestamp' }),
1038
+ escalationStep: integer('escalation_step').notNull().default(0),
1039
+ nextEscalationAt: integer('next_escalation_at', { mode: 'timestamp' }),
1040
+ /**
1041
+ * Quiet hours defer DELIVERY, never the escalation clock (D13). When a step
1042
+ * falls due inside someone's window the step is still taken — the step
1043
+ * index advances and the next one is scheduled — and only the message
1044
+ * waits, held here until the window ends.
1045
+ */
1046
+ deferredUntil: integer('deferred_until', { mode: 'timestamp' }),
1047
+ /** Route the deferred message is owed to. */
1048
+ deferredRouteId: text('deferred_route_id').references(() => routes.id, {
1049
+ onDelete: 'set null',
1050
+ }),
1051
+ escalationPolicyId: text('escalation_policy_id').references(() => escalationPolicies.id, {
1052
+ onDelete: 'set null',
1053
+ }),
1054
+ message: text('message').notNull(),
1055
+ details: text('details'),
1056
+ resolvedAt: integer('resolved_at', { mode: 'timestamp' }),
1057
+ },
1058
+ (table) => ({
1059
+ /**
1060
+ * One live alert per key. Resolved rows are exempt so history accumulates:
1061
+ * `activeKey` mirrors `key` while live and is NULL once resolved, and
1062
+ * SQLite treats NULLs in a unique index as distinct.
1063
+ */
1064
+ liveKey: uniqueIndex('alerts_live_key_idx').on(table.activeKey),
1065
+ keyLookup: index('alerts_key_idx').on(table.key),
1066
+ }),
1067
+ );
1068
+
1069
+ /**
1070
+ * One outbound message, and the token that authorises a reply to it.
1071
+ *
1072
+ * Per DELIVERY, not per alert: the token identifies WHO replied, which is
1073
+ * what "an ack from the secondary is broadcast to everyone paged" needs, and
1074
+ * doubles as the audit trail (design D10).
1075
+ */
1076
+ export const notificationDeliveries = sqliteTable('notification_deliveries', {
1077
+ id: text('id').primaryKey(), // UUID
1078
+ /** Short operator-typable token, unique across live deliveries. */
1079
+ token: text('token').notNull().unique(),
1080
+ kind: text('kind').$type<'alert' | 'interview'>().notNull(),
1081
+ /**
1082
+ * `alerts.id` when kind is `alert`; a BUS event id when kind is
1083
+ * `interview`. Deliberately not a foreign key — the event bus is a separate
1084
+ * SQLite database, so no FK can span it.
1085
+ */
1086
+ targetId: text('target_id').notNull(),
1087
+ routeId: text('route_id')
1088
+ .notNull()
1089
+ .references(() => routes.id, { onDelete: 'cascade' }),
1090
+ sentAt: integer('sent_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
1091
+ expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
1092
+ /** Set when a reply consumed this token. */
1093
+ consumedAt: integer('consumed_at', { mode: 'timestamp' }),
1094
+ });
1095
+
745
1096
  /**
746
1097
  * Type exports for use in application code
747
1098
  */
@@ -785,3 +1136,21 @@ export type AspectApproval = typeof aspectApprovals.$inferSelect;
785
1136
  export type NewAspectApproval = typeof aspectApprovals.$inferInsert;
786
1137
  export type ApiPrincipal = typeof apiPrincipals.$inferSelect;
787
1138
  export type NewApiPrincipal = typeof apiPrincipals.$inferInsert;
1139
+ export type Person = typeof people.$inferSelect;
1140
+ export type NewPerson = typeof people.$inferInsert;
1141
+ export type Route = typeof routes.$inferSelect;
1142
+ export type NewRoute = typeof routes.$inferInsert;
1143
+ export type EscalationPolicy = typeof escalationPolicies.$inferSelect;
1144
+ export type NewEscalationPolicy = typeof escalationPolicies.$inferInsert;
1145
+ export type EscalationStep = typeof escalationSteps.$inferSelect;
1146
+ export type NewEscalationStep = typeof escalationSteps.$inferInsert;
1147
+ export type Monitor = typeof monitors.$inferSelect;
1148
+ export type NewMonitor = typeof monitors.$inferInsert;
1149
+ export type MonitorRun = typeof monitorRuns.$inferSelect;
1150
+ export type NewMonitorRun = typeof monitorRuns.$inferInsert;
1151
+ export type SuppressionWindow = typeof suppressionWindows.$inferSelect;
1152
+ export type NewSuppressionWindow = typeof suppressionWindows.$inferInsert;
1153
+ export type Alert = typeof alerts.$inferSelect;
1154
+ export type NewAlert = typeof alerts.$inferInsert;
1155
+ export type NotificationDelivery = typeof notificationDeliveries.$inferSelect;
1156
+ export type NewNotificationDelivery = typeof notificationDeliveries.$inferInsert;