@almadar/std 16.216.0 → 16.217.0

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.
@@ -0,0 +1,522 @@
1
+ app std-permission-matrix "1.0.0"
2
+ "std-permission-matrix — role × module permission grant table / capability matrix admin surface. Use when a user voices that permissions must be managed per role and module (permission matrix, role permissions, capability matrix, permission grants, access matrix, who can see finance, hide margins, module-level permissions, admin permission screen, grant management, finance visibility by role, restrict modules by role). Persists PermissionGrant rows (workspaceId + role + module + capability flags canReadFinance / canSeeOthersHours / canSeeMargins) with full CRUD on an admin page mounted at /admin/permissions — create/edit forms are the std-modal ModalRecordModal substrate (the corpus's two-trait CRUD shape, mirroring std-list), persists live in a sibling coordinator trait, and adminRoles is ENFORCED at the persist boundary (a viewer whose role is not listed cannot fire a write, from the button or from the bus). Ships a query trait other traits consult event-wise: CHECK_GRANT { role, module } fetches the matching grant(s) and re-emits GRANT_RESOLVED { role, module, canReadFinance, canSeeOthersHours, canSeeMargins, found }. Grant SURFACE only — entity-layer @read/@update gate integration is a recorded core gap (gates are pure @entity/@user S-expressions and cannot consult another entity's rows), so enforcement stays with the consumer until that core lands."
3
+ orbital PermissionMatrixOrbital {
4
+ uses Modal from "std/behaviors/std-modal"
5
+ uses Spinner from "std/behaviors/ui-spinner"
6
+ uses Typography from "std/behaviors/ui-typography"
7
+ uses Icon from "std/behaviors/ui-icon"
8
+ uses Divider from "std/behaviors/ui-divider"
9
+ uses Alert from "std/behaviors/ui-alert"
10
+
11
+ type PermissionGrantLoaded = Event { data : [PermissionGrant] } "Fired when the permission-grant collection finishes loading"
12
+
13
+ type PermissionGrantLoadFailed = Event { error : string, code : string } "Fired when the permission-grant collection fails to load"
14
+
15
+ ;; Narrow id-only contract (std-list's ITEM_CREATED idiom), NOT a whole-row
16
+ ;; @entity flatten: the composed std-modal traits carry the intrinsic
17
+ ;; `seedRow` stash on this entity, which a row-flattened event would leak
18
+ ;; into the payload schema (always empty on real persist echoes).
19
+ type PermissionGrantSaved = Event { id : string! } "Fired after a grant row persists (create / update); carries the persisted row's id"
20
+
21
+ type PermissionGrantSaveFailed = Event { error : string, code : string } "Fired when a grant persist (create / update) fails"
22
+
23
+ type PermissionGrantDeleted = Event { id : string, deleted : boolean } "Fired after a grant row is deleted — persist delete emits the deleted row's id and a deleted flag, not the row"
24
+
25
+ type PermissionGrantDeleteFailed = Event { error : string, code : string } "Fired when a grant delete fails"
26
+
27
+ type GrantCheckLoaded = Event { data : [PermissionGrant] } "Fired when the grant lookup for a CHECK_GRANT finishes loading"
28
+
29
+ type GrantCheckFailed = Event { error : string, code : string } "Fired when the grant lookup for a CHECK_GRANT fails"
30
+
31
+ ;; Grant SURFACE only: entity-layer @read/@update gates are pure
32
+ ;; S-expressions over @entity (candidate row) + @user and cannot consult
33
+ ;; this table (orbital-core entity_access); gate-side enforcement is a
34
+ ;; recorded core gap, owner decision pending. Consumers consult event-wise
35
+ ;; via PermissionGrantQuery's CHECK_GRANT -> GRANT_RESOLVED below.
36
+ entity PermissionGrant [persistent: permission_grants] {
37
+ id : string!
38
+ workspaceId : string @description "Workspace the grant applies to; blank = applies to every workspace." @synonyms "workspace, tenant, org, organization" = ""
39
+ role : string! @description "The role the grant applies to (e.g. admin, manager, contractor)." @synonyms "user role, group, team role"
40
+ module : string! @description "The module or feature area the grant governs." @synonyms "feature, area, section, screen"
41
+ canReadFinance : boolean @description "Whether the role may read finance data in this module." @synonyms "see finance, read financials, view finance, finance access" = false
42
+ canSeeOthersHours : boolean @description "Whether the role may see other people's logged hours in this module." @synonyms "see others hours, view team hours, timesheet visibility, other people's time" = false
43
+ canSeeMargins : boolean @description "Whether the role may see margin / profit figures in this module." @synonyms "see margins, view profit, margin visibility, profitability" = false
44
+ createdAt : number @description "Timestamp indicating when the grant was created." @synonyms "creation time, created, date added" = 0
45
+ }
46
+
47
+ trait LoadingSpinner = Spinner.traits.SpinnerRender -> PermissionGrant {
48
+ config {}
49
+ }
50
+
51
+ trait ErrorSpinner = Spinner.traits.SpinnerRender -> PermissionGrant {
52
+ config {
53
+ size: sm
54
+ }
55
+ }
56
+
57
+ trait LoadingText = Typography.traits.TypographyRender -> PermissionGrant {
58
+ config {
59
+ color: muted
60
+ content: "Loading permission grants…"
61
+ variant: caption
62
+ }
63
+ }
64
+
65
+ trait ShieldCheckIcon = Icon.traits.IconRender -> PermissionGrant {
66
+ config {
67
+ name: shield-check
68
+ }
69
+ }
70
+
71
+ trait PermissionMatrixTitle = Typography.traits.TypographyRender -> PermissionGrant {
72
+ config {
73
+ content: "Permission matrix"
74
+ variant: h3
75
+ }
76
+ }
77
+
78
+ trait GrantDivider = Divider.traits.DividerRender -> PermissionGrant {
79
+ config {}
80
+ }
81
+
82
+ trait ErrorAlert = Alert.traits.AlertRender -> PermissionGrant {
83
+ config {
84
+ message: ?error
85
+ variant: error
86
+ }
87
+ }
88
+
89
+ ;; The admin browse surface: fetch + grid + the NEW/EDIT/DELETE broadcasts
90
+ ;; the composed modals and the persistor listen for. No form rendering here
91
+ ;; — the std-modal substrate owns open/submit/cancel/close (verified at
92
+ ;; source); a hand-rolled modal here put a form-submit affordance in a
93
+ ;; state this trait owned and stranded its cancel/close arms (runtime
94
+ ;; verify walk, 2026-09-15).
95
+ trait PermissionMatrixAdmin -> @rebindable PermissionGrant [interaction, collection, access] {
96
+ initial: loading
97
+
98
+ state loading {
99
+ INIT -> loading
100
+ (fetch PermissionGrant {
101
+ emit: { failure: PermissionGrantLoadFailed, success: PermissionGrantLoaded }
102
+ })
103
+ (render-ui main {
104
+ align: center
105
+ children: [@trait.LoadingSpinner, @trait.LoadingText]
106
+ className: py-12
107
+ direction: vertical
108
+ gap: md
109
+ type: stack
110
+ })
111
+ PermissionGrantLoaded -> listing
112
+ (render-ui main {
113
+ children: [{
114
+ align: center
115
+ children: [{
116
+ align: center
117
+ children: [@trait.ShieldCheckIcon, @trait.PermissionMatrixTitle]
118
+ direction: horizontal
119
+ gap: sm
120
+ type: stack
121
+ }, {
122
+ action: NEW
123
+ icon: plus
124
+ label: "New grant"
125
+ type: button
126
+ variant: primary
127
+ }]
128
+ direction: horizontal
129
+ gap: sm
130
+ justify: between
131
+ type: stack
132
+ }, @trait.GrantDivider, {
133
+ cols: 1
134
+ entity: ?data
135
+ fields: [{
136
+ label: Role
137
+ name: role
138
+ variant: h4
139
+ }, {
140
+ label: Module
141
+ name: module
142
+ variant: badge
143
+ }, {
144
+ label: Workspace
145
+ name: workspaceId
146
+ variant: caption
147
+ }, {
148
+ format: boolean
149
+ label: "Read finance"
150
+ name: canReadFinance
151
+ variant: caption
152
+ }, {
153
+ format: boolean
154
+ label: "Others' hours"
155
+ name: canSeeOthersHours
156
+ variant: caption
157
+ }, {
158
+ format: boolean
159
+ label: Margins
160
+ name: canSeeMargins
161
+ variant: caption
162
+ }]
163
+ gap: sm
164
+ itemActions: [{
165
+ event: EDIT
166
+ icon: pencil
167
+ label: Edit
168
+ variant: ghost
169
+ }, {
170
+ event: DELETE
171
+ icon: trash
172
+ label: Delete
173
+ variant: danger
174
+ }]
175
+ type: data-grid
176
+ }]
177
+ direction: vertical
178
+ gap: md
179
+ type: stack
180
+ })
181
+ PermissionGrantLoadFailed -> error
182
+ (render-ui main {
183
+ children: [@trait.ErrorAlert]
184
+ type: stack
185
+ })
186
+ }
187
+
188
+ state listing {
189
+ ;; Refetch target for the persistor's PermissionGrantSaved /
190
+ ;; PermissionGrantDeleted broadcasts (listens maps them onto INIT).
191
+ ;; Persist failures notify at the persistor itself — single notifier,
192
+ ;; no duplicate toasts here.
193
+ INIT -> loading
194
+ (fetch PermissionGrant {
195
+ emit: { failure: PermissionGrantLoadFailed, success: PermissionGrantLoaded }
196
+ })
197
+ }
198
+
199
+ state error {
200
+ INIT -> loading
201
+ (fetch PermissionGrant {
202
+ emit: { failure: PermissionGrantLoadFailed, success: PermissionGrantLoaded }
203
+ })
204
+ (render-ui main {
205
+ align: center
206
+ children: [@trait.ErrorSpinner]
207
+ type: stack
208
+ })
209
+ }
210
+
211
+ emits {
212
+ NEW
213
+ @description "Requests the create-grant form (the create modal listens)."
214
+ @synonyms "add grant, new permission, create grant"
215
+ @tier "domain"
216
+ EDIT {
217
+ id : string!
218
+ row : PermissionGrant
219
+ }
220
+ @description "Requests the edit form for a grant row (the edit modal listens)."
221
+ @synonyms "modify grant, change permission, update grant"
222
+ @tier "domain"
223
+ DELETE {
224
+ id : string!
225
+ row : PermissionGrant
226
+ }
227
+ @description "Requests deletion of a grant row (the persistor listens; adminRoles-guarded there)."
228
+ @synonyms "remove grant, revoke permission, delete grant"
229
+ @tier "domain"
230
+ PermissionGrantLoaded {
231
+ data : [PermissionGrant]
232
+ }
233
+ @description "Indicates the permission-grant collection has been successfully loaded."
234
+ @synonyms "loaded, fetched, retrieved"
235
+ @tier "internal"
236
+ PermissionGrantLoadFailed {
237
+ error : string
238
+ code : string
239
+ }
240
+ @description "Indicates failure to load the permission-grant collection."
241
+ @synonyms "load error, failed load, request failure"
242
+ @tier "internal"
243
+ }
244
+
245
+ listens {
246
+ PermissionGrantLoadFailed {
247
+ error : string
248
+ code : string
249
+ }
250
+ @description "Indicates failure to load the permission-grant collection."
251
+ @synonyms "load error, failed load"
252
+ @tier "internal"
253
+ PermissionGrantPersistor.PermissionGrantSaved -> INIT
254
+ PermissionGrantPersistor.PermissionGrantDeleted -> INIT
255
+ }
256
+
257
+ config {}
258
+ }
259
+
260
+ ;; Create modal: configured via `config`, no effects overrides (std-list
261
+ ;; idiom). SAVE is renamed GRANT_CREATED so the persistor distinguishes
262
+ ;; Create-SAVE from Edit-SAVE without source-scoped listens.
263
+ trait PermissionGrantCreate = Modal.traits.ModalRecordModal -> PermissionGrant {
264
+ events {
265
+ OPEN: NEW
266
+ SAVE: GRANT_CREATED
267
+ }
268
+
269
+ config {
270
+ icon: plus-circle
271
+ title: "Create grant"
272
+ fields: (workspaceId role module canReadFinance canSeeOthersHours canSeeMargins)
273
+ mode: create
274
+ }
275
+
276
+ listens {
277
+ PermissionMatrixAdmin.NEW -> NEW
278
+ NEW
279
+ @description "Opens the create-grant form."
280
+ @synonyms "add grant, new permission, create grant"
281
+ @tier "domain"
282
+ SAVE {
283
+ data : @entity!
284
+ }
285
+ @description "The create-grant form committed; data carries the new grant's fields."
286
+ @synonyms "submit grant, confirm create, persist"
287
+ @tier "domain"
288
+ ModalRecordLoadFailed {
289
+ error : string
290
+ code : string
291
+ }
292
+ @description "Indicates a failure to load a record for a modal."
293
+ @synonyms "error, failure, problem, unsuccessful"
294
+ @tier "internal"
295
+ }
296
+ }
297
+
298
+ ;; Edit modal: configured via `config`, no effects overrides. SAVE is
299
+ ;; renamed GRANT_UPDATED.
300
+ trait PermissionGrantEdit = Modal.traits.ModalRecordModal -> PermissionGrant {
301
+ events {
302
+ OPEN: EDIT
303
+ SAVE: GRANT_UPDATED
304
+ }
305
+
306
+ config {
307
+ icon: pencil
308
+ title: "Edit grant"
309
+ fields: (workspaceId role module canReadFinance canSeeOthersHours canSeeMargins)
310
+ mode: edit
311
+ }
312
+
313
+ listens {
314
+ PermissionMatrixAdmin.EDIT -> EDIT
315
+ EDIT {
316
+ id : string
317
+ row : PermissionGrant
318
+ }
319
+ @description "Opens the edit form for a grant row; the grid supplies the row."
320
+ @synonyms "modify grant, change permission, update grant"
321
+ @tier "domain"
322
+ SAVE {
323
+ data : @entity!
324
+ }
325
+ @description "The edit-grant form committed; data carries the grant id + updated fields."
326
+ @synonyms "submit edit, confirm update, persist"
327
+ @tier "domain"
328
+ ModalRecordLoadFailed {
329
+ error : string
330
+ code : string
331
+ }
332
+ @description "Indicates a failure to load a record into a modal window."
333
+ @synonyms "error, failure, problem, unsuccessful"
334
+ @tier "internal"
335
+ }
336
+ }
337
+
338
+ ;; Coordinator trait: owns the persist side-effects triggered by the modal
339
+ ;; emits and the grid's DELETE. adminRoles is ENFORCED here, at the persist
340
+ ;; boundary — both execution paths honor transition guards, for the form
341
+ ;; submit and for a bus-delivered event alike: empty = ungated, otherwise
342
+ ;; the viewer's role must be listed or the write does not fire. (A blocked
343
+ ;; viewer can still OPEN a form — the substrate modal owns open/close —
344
+ ;; but their submit is a no-op; route-level gating of /admin/permissions
345
+ ;; stays the host's job.)
346
+ trait PermissionGrantPersistor -> PermissionGrant [lifecycle, instance, access] {
347
+ initial: idle
348
+
349
+ state idle {
350
+ INIT -> idle
351
+ DO_CREATE -> idle when (or ["=", (array/len @config.adminRoles), 0] (array/includes @config.adminRoles @user.role))
352
+ (persist create PermissionGrant ?data {
353
+ emit: { failure: PermissionGrantSaveFailed, success: PermissionGrantSaved }
354
+ })
355
+ DO_UPDATE -> idle when (or ["=", (array/len @config.adminRoles), 0] (array/includes @config.adminRoles @user.role))
356
+ (persist update PermissionGrant ?data {
357
+ emit: { failure: PermissionGrantSaveFailed, success: PermissionGrantSaved }
358
+ })
359
+ DO_DELETE -> idle when (or ["=", (array/len @config.adminRoles), 0] (array/includes @config.adminRoles @user.role))
360
+ (persist delete PermissionGrant ?id {
361
+ emit: { failure: PermissionGrantDeleteFailed, success: PermissionGrantDeleted }
362
+ })
363
+ PermissionGrantSaveFailed -> idle
364
+ (notify error (str/concat "Couldn't save the permission grant — " ?error))
365
+ PermissionGrantDeleteFailed -> idle
366
+ (notify error (str/concat "Couldn't delete the permission grant — " ?error))
367
+ }
368
+
369
+ emits {
370
+ PermissionGrantSaved -> external {
371
+ id : string!
372
+ }
373
+ @description "Signals that a permission grant has been persisted (create / update); carries the persisted row's id."
374
+ @synonyms "saved, created, updated, persisted"
375
+ @tier "domain"
376
+ PermissionGrantSaveFailed -> external {
377
+ error : string
378
+ code : string
379
+ }
380
+ @description "Indicates a failure to persist a permission grant."
381
+ @synonyms "save error, failed save, persist failure"
382
+ @tier "internal"
383
+ PermissionGrantDeleted -> external {
384
+ id : string
385
+ deleted : boolean
386
+ }
387
+ @description "Signals that a permission grant has been deleted."
388
+ @synonyms "deleted, removed, revoked"
389
+ @tier "domain"
390
+ PermissionGrantDeleteFailed -> external {
391
+ error : string
392
+ code : string
393
+ }
394
+ @description "Indicates a failure to delete a permission grant."
395
+ @synonyms "delete error, failed delete"
396
+ @tier "internal"
397
+ }
398
+
399
+ listens {
400
+ DO_CREATE {
401
+ data : PermissionGrant!
402
+ }
403
+ @description "Indicates a request to create a grant row."
404
+ @synonyms "add, new, insert"
405
+ @tier "domain"
406
+ DO_UPDATE {
407
+ data : PermissionGrant!
408
+ }
409
+ @description "Indicates a grant row has been modified."
410
+ @synonyms "modify, update, change"
411
+ @tier "domain"
412
+ DO_DELETE {
413
+ id : string!
414
+ }
415
+ @description "Indicates a request to delete a grant row."
416
+ @synonyms "remove, discard, erase"
417
+ @tier "domain"
418
+ PermissionGrantCreate.GRANT_CREATED -> DO_CREATE
419
+ PermissionGrantEdit.GRANT_UPDATED -> DO_UPDATE
420
+ PermissionMatrixAdmin.DELETE -> DO_DELETE
421
+ }
422
+
423
+ config {
424
+ adminRoles : [string] = []
425
+ @label "Who can manage grants?"
426
+ @description "Roles eligible to create, edit, or delete grants. ENFORCED by the DO_CREATE/DO_UPDATE/DO_DELETE transition guards: a viewer whose @user.role is not listed cannot fire a write, from the form submit or from the bus. Empty = every signed-in viewer may manage grants (ungated). Mounting the page is the opt-in — there is deliberately no separate enable flag (a default-off flag made the whole page's affordances dead on a fresh mount)."
427
+ @synonyms "admin roles, permission managers, who edits permissions, grant admins, permission matrix, role permissions, capability matrix, permission grants, access matrix, manage permissions"
428
+ @tier "policy"
429
+ }
430
+ }
431
+
432
+ trait PermissionGrantQuery -> @rebindable PermissionGrant [lifecycle, instance, access] {
433
+ initial: idle
434
+
435
+ state idle {
436
+ ;; The fetch filter binds @entity to the candidate ROW, so the queried
437
+ ;; role/module travel by payload (?role/?module) in the filter and are
438
+ ;; remembered on the trait instance for the GRANT_RESOLVED echo.
439
+ CHECK_GRANT -> idle when @config.enabled
440
+ (set @entity.role ?role)
441
+ (set @entity.module ?module)
442
+ (fetch PermissionGrant {
443
+ filter: (and ["=", (object/get @entity role), ?role] ["=", (object/get @entity module), ?module])
444
+ emit: { failure: GrantCheckFailed, success: GrantCheckLoaded }
445
+ })
446
+ ;; found: false = no matching row — whether that denies or permits is
447
+ ;; the CONSUMER's choice (default-deny is the consumer's decision); this
448
+ ;; atom reports, it never decides.
449
+ GrantCheckLoaded -> idle
450
+ (emit GRANT_RESOLVED {
451
+ role: @entity.role
452
+ module: @entity.module
453
+ canReadFinance: (if [">", (array/len ?data), 0] (object/get (array/first ?data) canReadFinance false) false)
454
+ canSeeOthersHours: (if [">", (array/len ?data), 0] (object/get (array/first ?data) canSeeOthersHours false) false)
455
+ canSeeMargins: (if [">", (array/len ?data), 0] (object/get (array/first ?data) canSeeMargins false) false)
456
+ found: [">", (array/len ?data), 0]
457
+ })
458
+ GrantCheckFailed -> idle
459
+ (notify error (str/concat "Couldn't resolve the permission grant — " ?error))
460
+ }
461
+
462
+ emits {
463
+ GRANT_RESOLVED -> external {
464
+ role : string
465
+ module : string
466
+ canReadFinance : boolean
467
+ canSeeOthersHours : boolean
468
+ canSeeMargins : boolean
469
+ found : boolean
470
+ }
471
+ @description "Answers a CHECK_GRANT: the capability flags of the first matching grant row, with found=false (and all flags false) when no row matches. Rename to wire the downstream listener; default-deny on found=false is the consumer's choice."
472
+ @synonyms "grant resolved, permission answer, capabilities returned, access decision data"
473
+ @tier "domain"
474
+ GrantCheckLoaded {
475
+ data : [PermissionGrant]
476
+ }
477
+ @description "Indicates the grant lookup for a CHECK_GRANT has been successfully loaded."
478
+ @synonyms "loaded, fetched, retrieved"
479
+ @tier "internal"
480
+ GrantCheckFailed {
481
+ error : string
482
+ code : string
483
+ }
484
+ @description "Indicates the grant lookup for a CHECK_GRANT failed."
485
+ @synonyms "lookup error, failed lookup, request failure"
486
+ @tier "internal"
487
+ }
488
+
489
+ listens {
490
+ CHECK_GRANT {
491
+ role : string!
492
+ module : string!
493
+ }
494
+ @description "Asks which capabilities a role holds in a module; the answer arrives as GRANT_RESOLVED (asynchronous — correlate by the echoed role/module)."
495
+ @synonyms "check grant, lookup permission, resolve capabilities, can this role see"
496
+ @tier "domain"
497
+ GrantCheckLoaded {
498
+ data : [PermissionGrant]
499
+ }
500
+ @description "Indicates the grant lookup has been successfully retrieved."
501
+ @synonyms "loaded, fetched, retrieved"
502
+ @tier "internal"
503
+ GrantCheckFailed {
504
+ error : string
505
+ code : string
506
+ }
507
+ @description "Indicates the grant lookup failed."
508
+ @synonyms "lookup error, failed lookup"
509
+ @tier "internal"
510
+ }
511
+
512
+ config {
513
+ enabled : boolean = false
514
+ @label "Answer permission lookups"
515
+ @description "Answer CHECK_GRANT lookups with GRANT_RESOLVED from the persisted grant table. Off by default."
516
+ @synonyms "enable, turn on, activate, permission lookup, grant query, resolve capabilities"
517
+ @tier "policy"
518
+ }
519
+ }
520
+
521
+ page "/admin/permissions" as PermissionMatrixPage -> PermissionMatrixAdmin, PermissionGrantCreate, PermissionGrantEdit, PermissionGrantPersistor, PermissionGrantQuery
522
+ }
@@ -1,5 +1,5 @@
1
1
  app std-reminder-scheduler "1.1.0"
2
- "std-reminder-scheduler — deadline reminders for a target entity. A periodic tick scans the target for records whose `dateField` deadline (a ms-epoch number or an ISO datetime string) is within `offsetHours` of now and that have not been reminded yet, and emits `ReminderDue` for each — every matching record, via a self-driving scan loop. Marks each record `remindedAt` so it isn't reminded twice. Compose alongside std-notify-on-event to deliver the reminder."
2
+ "std-reminder-scheduler — deadline reminders for a target entity. A periodic tick scans the target for records whose `dateField` deadline (a ms-epoch number or an ISO datetime string) is within `offsetHours` of now and that have not been reminded yet, and emits `ReminderDue` for each — every matching record, via a self-driving scan loop. `direction` picks which side of now the scan matches: 'future' (default) fires for deadlines up to `offsetHours` ahead; 'past' fires once a record's date is at least `offsetHours` behind now. Marks each record `remindedAt` so it isn't reminded twice — and only when the record actually matched the scan, so records outside the window stay eligible for later passes. Compose alongside std-notify-on-event to deliver the reminder."
3
3
  orbital ReminderSchedulerOrbital {
4
4
  type ScanRecordsLoaded = Event { data : [@entity @config.targetEntity] } "Internal: target records due for a reminder."
5
5
 
@@ -36,6 +36,9 @@ orbital ReminderSchedulerOrbital {
36
36
  emit: { failure: ScanFailed, success: ReminderStepped }
37
37
  })
38
38
  ScanRecordsLoaded -> idle when ["=", (array/len ?data), 0]
39
+ ;; A disabled scan acknowledges the batch without marking anything —
40
+ ;; the dedup write fires only for a record that actually matched.
41
+ ScanRecordsLoaded -> idle when ["=", @config.enabled, false]
39
42
  ScanFailed -> idle
40
43
  (notify error (str/concat "Couldn't scan for reminders — " ?error))
41
44
  }
@@ -44,7 +47,7 @@ orbital ReminderSchedulerOrbital {
44
47
  ReminderStepped -> scanning
45
48
  (fetch @config.targetEntity {
46
49
  emit: { failure: ScanFailed, success: ScanRecordsLoaded }
47
- filter: (and ["=", (object/get @entity remindedAt), 0] [">", (time/parse (object/get @entity @config.dateField)), @now] ["<", ["-", (time/parse (object/get @entity @config.dateField)), @now], ["*", @config.offsetHours, 3600000]])
50
+ filter: (and ["=", (object/get @entity remindedAt), 0] (or (and ["=", @config.direction, "future"] [">", (time/parse (object/get @entity @config.dateField)), @now] ["<", ["-", (time/parse (object/get @entity @config.dateField)), @now], ["*", @config.offsetHours, 3600000]]) (and ["=", @config.direction, "past"] [">", ["-", @now, (time/parse (object/get @entity @config.dateField))], ["*", @config.offsetHours, 3600000]])))
48
51
  })
49
52
  ScanRecordsLoaded -> scanning when [">", (array/len ?data), 0]
50
53
  (set @entity.headId (object/get (array/first ?data) id))
@@ -96,7 +99,7 @@ orbital ReminderSchedulerOrbital {
96
99
  scanCycle every "*/15 * * * *"
97
100
  (fetch @config.targetEntity {
98
101
  emit: { failure: ScanFailed, success: ScanRecordsLoaded }
99
- filter: (and ["=", (object/get @entity remindedAt), 0] [">", (time/parse (object/get @entity @config.dateField)), @now] ["<", ["-", (time/parse (object/get @entity @config.dateField)), @now], ["*", @config.offsetHours, 3600000]])
102
+ filter: (and ["=", (object/get @entity remindedAt), 0] (or (and ["=", @config.direction, "future"] [">", (time/parse (object/get @entity @config.dateField)), @now] ["<", ["-", (time/parse (object/get @entity @config.dateField)), @now], ["*", @config.offsetHours, 3600000]]) (and ["=", @config.direction, "past"] [">", ["-", @now, (time/parse (object/get @entity @config.dateField))], ["*", @config.offsetHours, 3600000]])))
100
103
  })
101
104
 
102
105
  }
@@ -122,6 +125,11 @@ orbital ReminderSchedulerOrbital {
122
125
  @description "A reminder fires once the deadline is within this many hours of now."
123
126
  @synonyms "advance notice, lead time, heads up, early warning hours, how far ahead"
124
127
  @tier "infra"
128
+ direction : "future" | "past" = "future"
129
+ @label "Scan which side of now?"
130
+ @description "'future' (default) matches records whose dateField is still ahead, up to offsetHours out — upcoming-deadline reminders. 'past' matches records whose dateField is at least offsetHours behind now — act-after-the-fact policies. The emitted event and the dedup marker are identical either way."
131
+ @synonyms "direction, past due, overdue, after the deadline, look back, backward scan, past-window"
132
+ @tier "infra"
125
133
  severity : string = info
126
134
  @label "How urgent are the reminders?"
127
135
  @description "Severity passed to the notifier: info / warning / error."