Phase 6: schedules, blocklists, allowlists (editors + effective state)

- Weekly schedule editor per device: a Monday-Sunday grid of blocking
  windows, including windows that cross midnight.
- Blocklist/allowlist editor per device: app (Android package / Windows
  process) and site (hostname) entries, tagged by the device platform.
- Effective desired state: a manual lock, an active schedule window, or
  neither. While blocked the allowlist is honoured (Spotify stays while
  other apps are blocked). Published retained with reason schedule/manual.
- Boundary-aware republish loop so a window turns on and off on time,
  and manual lock expiry is cleared promptly.
- CONTROL_TIMEZONE (default Australia/Sydney) with embedded tzdata, so
  wall-clock windows are evaluated in the family zone, not the container.
- Every change is written to the append-only audit log (FR-15).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Paperclip
2026-10-13 16:12:02 +11:00
parent 2f8e788fe6
commit 9af543c394
21 changed files with 2341 additions and 94 deletions

View File

@@ -34,9 +34,11 @@ type ReportSink interface {
}
// ConfigSource reads the desired state that is published to devices.
// EffectiveState combines the manual lock, the weekly schedule and the
// allow/block lists (Phase 6).
type ConfigSource interface {
ListDevices(ctx context.Context) ([]store.Device, error)
DeviceLists(ctx context.Context, id int64) (allow, block []string, err error)
EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error)
}
// Options configures the link.
@@ -60,6 +62,10 @@ type Options struct {
// Actor is the audit-log actor for reports that arrive over the broker.
Actor string
// Location is the schedule timezone. Weekly windows are wall-clock, so the
// service must evaluate them in the family's zone, not the container's
// (usually UTC). Nil means time.Local.
Location *time.Location
// Log receives structured logs.
Log *slog.Logger
@@ -325,27 +331,43 @@ func (l *Link) noteMessage() {
// topic stored with the device. It is the single writer path for config
// (AGENTS.md §10 rule 5): only the Control Service publishes.
func (l *Link) PublishDeviceConfig(ctx context.Context, dev store.Device) error {
allow, block, err := l.opts.Source.DeviceLists(ctx, dev.ID)
if err != nil {
return err
}
payload, err := Config{
V: ConfigVersion,
DeviceID: dev.ID,
Name: dev.Name,
Platform: dev.Platform,
Locked: dev.Locked,
Until: dev.Until,
Allow: allow,
Block: block,
GeneratedAt: time.Now().UTC(),
}.Marshal()
payload, err := l.buildConfig(ctx, dev)
if err != nil {
return err
}
return l.Publish(dev.Topic, payload)
}
// buildConfig computes the effective desired state for one device at the current
// time and renders the retained payload. It is separated from the publish so it
// can be tested without a broker.
func (l *Link) buildConfig(ctx context.Context, dev store.Device) ([]byte, error) {
eff, err := l.opts.Source.EffectiveState(ctx, dev, time.Now().In(l.location()))
if err != nil {
return nil, err
}
return Config{
V: ConfigVersion,
DeviceID: dev.ID,
Name: dev.Name,
Platform: dev.Platform,
Locked: eff.Locked,
Until: eff.Until,
Allow: eff.Allow,
Block: eff.Block,
Reason: eff.Reason,
GeneratedAt: time.Now().UTC(),
}.Marshal()
}
// location returns the schedule timezone, defaulting to the process local zone.
func (l *Link) location() *time.Location {
if l.opts.Location != nil {
return l.opts.Location
}
return time.Local
}
// Publish sends one retained message and waits for the broker to accept it.
func (l *Link) Publish(topic string, payload []byte) error {
if !l.client.IsConnected() {