moon-change-window-auditor

    Dependency-aware maintenance window collision auditing, change freezes, policy checks and safe-slot planning for MoonBit

    maintenance-window
    change-management
    change-freeze
    dependency
    audit
    policy
    scheduling
    sre
    Download zip
    Author
    Version
    0.2.0
    License
    MIT
    Last updated
    last month
    Downloads
    26

    No dependencies

    #Moon Change Window Auditor

    MoonBit checks

    MoonBit 原生的变更窗口审计与排期库:解析多服务维护变更记录,审计窗口重叠、依赖顺序、循环依赖与变更冻结期冲突,按运维策略判定计划能否放行,并计算可插入新变更的安全空档与最早可行排期。输入是易审阅的文本记录,输出是结构化结果以及文本、JSON、Markdown、CSV 和 ASCII 时间线五种报告。

    #目标与边界

    • 纯 MoonBit 包,无第三方依赖,native / wasm / wasm-gc / js 四个后端行为一致。
    • 离线且确定性:同样的输入总是给出逐字节相同的报告,不访问数据库、网络或系统时钟。
    • 时间按单一未命名时区解释,只接受 1970-01-01T00:00 到 2199-12-31T23:59 范围内的分钟级时间。
    • 不做通用日历、cron、iCalendar、跨时区换算、节假日计算、发布系统、任务调度器或通用图数据库。
    • 安全空档只看现有记录占用的时间;它不推导新变更自身的依赖链,也不做多目标优化。
    • 规模按离线单机设计:审计按窗口两两比较,适合数百条记录量级的变更计划。

    #环境要求

    • MoonBit 工具链,moonc 不低于 0.10.14(本仓库在 moonc v0.10.14+7d59c7ec9 上验证)。
    • 无外部包依赖:moon.pkg 不导入任何模块。

    #安装

    当前从源码使用(尚未发布到 Mooncakes):

    git clone https://github.com/chen-657/moon-change-window-auditor cd moon-change-window-auditor moon check --deny-warn --target all

    在自己的 MoonBit 模块中作为依赖引用时,按包名导入并取一个别名:

    // moon.pkg
    import {
    "chen-657/moon-change-window-auditor" @audit,
    }

    #输入格式

    每行一条记录,字段用 ; 分隔,每个字段写成 key=value:

    id=database;service=orders-db;start=2026-10-01T22:00;end=2026-10-01T23:00;risk=high;depends= id=api;service=orders-api;start=2026-10-01T22:45;end=2026-10-01T23:15;risk=medium;depends=database id=blackout;service=company;kind=freeze;start=2026-12-24T00:00;end=2026-12-27T00:00

    字段必填说明
    id是记录标识。ASCII 字母、数字、_、.、-,不超过 64 字符,全文件唯一
    service否服务名,只用于报告与汇总,可以为空
    start是YYYY-MM-DDTHH:MM,也接受用空格代替 T
    end是同上,必须晚于 start
    risk否low、medium、high 之一,缺省 medium,大小写敏感;冻结期忽略此字段
    depends否逗号分隔的记录 ID,空值表示没有依赖;冻结期不允许声明依赖
    kind否change(缺省,也接受 window)或 freeze,后者表示变更冻结期

    空行、以 # 开头的注释行和空字段(例如结尾多余的 ;)都会被忽略,但行号照常计入诊断信息。

    #快速开始

    // 1. 解析
    let plan = @audit.parse_plan(source)

    // 2. 审计:重叠、依赖顺序、冻结期冲突
    let report = @audit.audit(plan)
    println(@audit.render_report(report))

    // 3. 按策略判定能否放行
    let policy_report = @audit.audit_with_policy(plan, @audit.Policy::strict())
    println(@audit.render_policy_report(policy_report))

    // 4. 找可安排新变更的空档,并检查候选窗口
    match (@audit.parse_instant("2026-10-01T20:00"), @audit.parse_instant("2026-10-02T02:00")) {
    (Some(from), Some(to)) => {
    println(@audit.render_slots(@audit.safe_slots(plan, from, to, 30)))
    let candidate = @audit.candidate_slot(from, from + 60)
    println(@audit.can_schedule(plan, candidate, [], 30).to_line())
    }
    _ => println("invalid horizon")
    }

    #公开 API 一览

    完整签名见生成的 pkg.generated.mbti(138 个公开函数、26 个公开类型)。

    领域主要接口
    时间parse_instant、format_instant、format_duration
    解析与模型parse_plan、Plan::find、Plan::changes、Plan::freezes、Plan::windows_of_kind、Window::duration、Window::is_freeze
    审计audit、freeze_conflicts、freezes_hit_by、AuditReport::status、AuditReport::error_count
    区间代数merge_slots、sort_slots、clip_slot、invert_slots、intersect_slots、subtract_slots、total_duration、slot_at、Slot::contains、Slot::overlaps、Slot::touches、Slot::intersect、Slot::merge_with
    阻塞与空档blocked_intervals、blocked_minutes、safe_slots
    并发负载concurrency_at、service_concurrency_at、peak_concurrency、busiest_intervals、max_concurrency_in、load_profile、change_minutes、freeze_minutes
    依赖图dependencies_of、dependents_of、transitive_dependencies、dependency_depth、find_cycles、graph_roots、graph_leaves、execution_order
    排期can_schedule、earliest_start、slack_minutes、earliest_schedule、next_slots、candidate_slot
    策略Policy::standard、Policy::strict、Policy::relaxed、Policy::describe、audit_with_policy、PolicyReport::status
    汇总与对比summarize、find_service、diff_plans、PlanDiff::is_empty
    渲染render_report、render_slots、render_policy_report、render_summary、render_diff、render_execution_order、render_schedule_result、render_timeline、render_report_json、render_plan_json、render_slots_json、render_report_markdown、render_windows_markdown、render_slots_markdown、render_windows_csv、render_overlaps_csv、render_freeze_conflicts_csv、render_dependency_issues_csv、render_slots_csv

    #审计规则

    • 重叠:两条记录共享至少一分钟即为重叠,报告共享分钟数。首尾相接(一条 23:00 结束、另一条 23:00 开始)不算重叠,但会合并为同一段阻塞区间。严重度由风险等级决定:两个 high 为 critical,其中一个为 high 为 major,其余为 minor。
    • 依赖顺序:依赖必须在依赖方开始之前结束,否则记 out-of-order error 并给出超出的时长。
    • 零余量:依赖恰好在前一条记录结束的同一分钟开始,记 back-to-back warning,不算失败但没有缓冲。
    • 缺失依赖 / 自依赖:分别记 missing-dependency、self-dependency error。
    • 循环依赖:find_cycles 按“从最小 ID 起”规范化的顺序报告每个环一次;execution_order 对有环计划返回 None。
    • 冻结期冲突:变更落在 kind=freeze 记录内记 freeze-conflict error。冻结期之间互相重叠不算冲突。
    • 重复 ID:记 duplicate-id error,两条记录都保留,依赖解析取第一条。
    • 无效记录:字段缺失、时间非法、结束不晚于开始、ID 或依赖 ID 非法、kind 非法的记录会被丢弃并记 error;被丢弃的记录不参与重叠与依赖判断。
    • 未知字段:忽略并记 warning,记录保留。

    #策略规则

    audit_with_policy 在审计之上叠加运维策略,违规明细按固定顺序输出:

    code严重度触发条件subject
    max-concurrenterror同时运行的变更数超过 max_concurrent(先全计划,再逐服务)all 或服务名
    max-change-minuteserror单条变更时长超过 max_change_minutes(不计冻结期)记录 ID
    min-dependency-slackwarning依赖结束到依赖方开始的余量小于 min_dependency_slack_minutes记录 ID
    high-risk-overlaperrorforbid_high_risk_overlap 为真且高风险变更与其他变更重叠(每对一次)高风险记录 ID
    max-high-risk-per-dayerror某一天的高风险变更数超过 max_high_risk_per_day当天 00:00
    freeze-conflicterrorforbid_freeze_conflicts 为真且变更落在冻结期内记录 ID
    service-missingwarningrequire_service 为真且变更没有服务名记录 ID

    0 表示不限,false 表示关闭该规则,因此 Policy::relaxed() 不产生任何违规。只有 warning 时 status 仍为 PASS。

    #输出格式

    同一次审计可以渲染成五种形式,全部为确定性输出:

    • 文本:render_report / render_policy_report / render_summary / render_diff,可直接贴进工单或用 grep 取用。status: 行是给审批机器人看的结论。
    • JSON:render_report_json / render_plan_json / render_slots_json,两空格缩进、字段间有逗号、空数组内联为 [],已用真实 JSON 解析器验证。
    • Markdown:render_report_markdown / render_windows_markdown / render_slots_markdown,GitHub 风格表格,单元格内的 | 转义为 \|。
    • CSV:render_windows_csv / render_overlaps_csv / render_freeze_conflicts_csv / render_dependency_issues_csv / render_slots_csv,含逗号、引号或换行的字段按 RFC 4180 风格加引号并转义。
    • ASCII 时间线:render_timeline(plan, from, to, columns),. 空闲、# 变更、= 冻结期、! 落在冻结期内的变更:

    Timeline 2026-10-01T21:00 -> 2026-10-02T04:00 (7h, 28 columns, 1 column = 15m) database |....####....................| api |......########..............| blackout |................====!!!!....| cache |....................!!!!##..| legend: . free, # change, = freeze, ! change inside freeze

    #示例

    moon run examples/demo # 审计一个含重叠、依赖错误、缺失依赖的计划 moon run examples/policy-gate # 按严格策略判定计划,并输出汇总、排期与时间线

    examples/policy-gate 的输出(节选):

    POLICY VIOLATIONS warning min-dependency-slack (api): overlaps database and misses the minimum slack of 30m by 30m error high-risk-overlap (database): high risk database overlaps api for 30m error freeze-conflict (cache): runs inside freeze blackout for 1h policy: FAIL Plan summary changes: 3 freezes: 1 blocked: 5h workload: 4h30m peak: 2 changes at 2026-10-01T22:30 Earliest dependency aware schedule (3 changes) database 2026-10-01T22:00 -> 2026-10-01T23:00 (+0m) api 2026-10-01T23:00 -> 2026-10-02T01:00 (+30m) cache 2026-10-02T02:00 -> 2026-10-02T03:30 (+0m)

    #本地检查

    moon version --all moon check --deny-warn --target all moon build --target all moon test --target all moon fmt moon info moon run examples/demo moon run examples/policy-gate

    check、build、test 与两个示例覆盖四个后端;fmt 与 info 之后工作树应保持干净(CI 用 git diff --exit-code 校验)。测试覆盖时间解析边界、每一种诊断与违规码、重叠与依赖规则、冻结期、区间代数、并发负载、依赖图、排期压缩、汇总与 diff,以及五种渲染格式的完整文本。

    #项目结构

    文件职责
    moon.mod / moon.pkg模块元数据;库包不导入任何依赖
    text.mbt数字解析、补齐、拼接、标识符校验、字符串比较与排序
    time.mbt分钟轴、YYYY-MM-DDTHH:MM 解析与格式化、时长渲染
    types.mbt数据模型:Plan、Window、Slot、Overlap、FreezeConflict、DependencyIssue、AuditReport 等
    parse.mbt文本格式解析与全部解析诊断
    intervals.mbt半开区间代数、合并、取反、交、差
    audit.mbt重叠检测、依赖顺序检查、冻结期冲突
    concurrency.mbt并发计数、峰值、拥挤时段、负载采样
    graph.mbt依赖图查询、环检测、拓扑执行顺序
    schedule.mbt候选窗口检查、最早开始、依赖感知排期
    policy.mbt策略模型、违规推导、策略报告
    summary.mbt计划汇总与按服务统计
    diff.mbt两版计划对比
    report.mbt文本报告
    render_json.mbt / render_markdown.mbt / render_csv.mbt / render_timeline.mbt其余四种输出
    examples/demo / examples/policy-gate可运行示例
    *_test.mbt测试包
    .github/workflows/check.ymlCI:check、build、test、示例、格式与接口一致性

    #许可证与来源

    项目采用 MIT 许可证。算法出处、查重范围、确定性做法与后端兼容性见 SOURCES.md。公开仓库、Release 与 Mooncakes 包都不包含报名或申报材料。

    AuditReport

    pub(all) struct AuditReport {
    plan : Plan
    overlaps : Array[Overlap]
    dependency_issues : Array[DependencyIssue]
    freeze_conflicts : Array[FreezeConflict]
    blocked_minutes : Int
    }

    Result of auditing a plan.

    AuditReport::change_count

    fn AuditReport::change_count(self : AuditReport) -> Int

    Number of audited records that are planned changes.

    AuditReport::error_count

    fn AuditReport::error_count(self : AuditReport) -> Int

    Number of errors: parse errors, overlaps, freeze conflicts and error level dependency issues.

    AuditReport::freeze_count

    fn AuditReport::freeze_count(self : AuditReport) -> Int

    Number of audited records that are change freezes.

    AuditReport::has_errors

    fn AuditReport::has_errors(self : AuditReport) -> Bool

    True when the audit found at least one error.

    AuditReport::status

    fn AuditReport::status(self : AuditReport) -> String

    PASS when there is no error and FAIL otherwise. Warnings do not fail an audit.

    AuditReport::warning_count

    fn AuditReport::warning_count(self : AuditReport) -> Int

    Number of warnings: parse warnings and warning level dependency issues.

    AuditReport::window_count

    fn AuditReport::window_count(self : AuditReport) -> Int

    Number of records that were audited.

    ChangeKind

    pub(all) enum ChangeKind {
    Added
    Removed
    Modified
    } derive(Eq)

    What happened to one record between two revisions.

    ChangeKind::equal

    fn ChangeKind::equal(ChangeKind, ChangeKind) -> Bool

    ChangeKind::label

    fn ChangeKind::label(self : ChangeKind) -> String

    Lower case label: added, removed or modified.

    ChangeKind::not_equal

    fn ChangeKind::not_equal(x : ChangeKind, y : ChangeKind) -> Bool

    DependencyIssue

    pub(all) struct DependencyIssue {
    window_id : String
    dependency : String
    problem : DependencyProblem
    severity : Severity
    detail : String
    }

    A problem between one window and one of the windows it depends on.

    DependencyIssue::to_line

    fn DependencyIssue::to_line(self : DependencyIssue) -> String

    Render as error api -> database: out-of-order (the dependency ends 45m after the dependent starts).

    DependencyProblem

    pub(all) enum DependencyProblem {
    MissingDependency
    SelfDependency
    OutOfOrder
    BackToBack
    } derive(Eq)

    Kind of dependency problem reported by audit.

    DependencyProblem::equal

    DependencyProblem::label

    fn DependencyProblem::label(self : DependencyProblem) -> String

    Kebab case label: missing-dependency, self-dependency, out-of-order or back-to-back.

    DependencyProblem::not_equal

    fn DependencyProblem::not_equal(x : DependencyProblem, y : DependencyProblem) -> Bool

    Diagnostic

    pub(all) struct Diagnostic {
    severity : Severity
    code : String
    line : Int
    detail : String
    }

    One problem found while parsing or auditing.

    Diagnostic::to_line

    fn Diagnostic::to_line(self : Diagnostic) -> String

    Render as error line 3: invalid-instant (start="2026-13-01T22:00").

    The line part is omitted when the diagnostic is not tied to an input line.

    FreezeConflict

    pub(all) struct FreezeConflict {
    window_id : String
    freeze_id : String
    minutes : Int
    }

    A planned change that runs inside a change freeze.

    FreezeConflict::to_line

    fn FreezeConflict::to_line(self : FreezeConflict) -> String

    Render as api runs inside freeze blackout: 45m.

    Impact

    pub(all) enum Impact {
    Critical
    Major
    Minor
    } derive(Eq)

    Operational impact of an overlap, derived from the risk of both windows.

    Impact::equal

    fn Impact::equal(Impact, Impact) -> Bool

    Impact::label

    fn Impact::label(self : Impact) -> String

    Lower case label: critical, major or minor.

    Impact::not_equal

    fn Impact::not_equal(x : Impact, y : Impact) -> Bool

    LoadPoint

    pub(all) struct LoadPoint {
    start : Int
    count : Int
    }

    One sample of a load profile.

    LoadPoint::to_line

    fn LoadPoint::to_line(self : LoadPoint) -> String

    Render as 2026-10-01T22:00 load 3.

    Overlap

    pub(all) struct Overlap {
    first : String
    second : String
    minutes : Int
    impact : Impact
    }

    Two windows that share at least one minute.

    Overlap::to_line

    fn Overlap::to_line(self : Overlap) -> String

    Render as api vs database: 45m (major).

    PeakConcurrency

    pub(all) struct PeakConcurrency {
    minute : Int
    count : Int
    }

    Highest number of changes that run at the same time.

    PeakConcurrency::to_line

    fn PeakConcurrency::to_line(self : PeakConcurrency) -> String

    Render as 3 changes at 2026-10-01T22:00.

    Plan

    pub(all) struct Plan {
    windows : Array[Window]
    diagnostics : Array[Diagnostic]
    }

    A parsed set of change windows together with everything reported while parsing.

    Plan::changes

    fn Plan::changes(self : Plan) -> Array[Window]

    Planned changes, in input order.

    Plan::find

    fn Plan::find(self : Plan, id : String) -> Window?

    First window with the given id in input order, or None.

    Duplicate ids are reported as errors while parsing; this lookup keeps using the first record so that a plan with duplicates still resolves every dependency to exactly one window.

    Plan::freezes

    fn Plan::freezes(self : Plan) -> Array[Window]

    Change freezes, in input order.

    Plan::has_errors

    fn Plan::has_errors(self : Plan) -> Bool

    True when parsing produced at least one error. Records with an error are dropped from windows, so the plan itself is still safe to inspect.

    Plan::window_count

    fn Plan::window_count(self : Plan) -> Int

    Number of windows that were accepted.

    Plan::windows_of_kind

    fn Plan::windows_of_kind(self : Plan, kind : WindowKind) -> Array[Window]

    Records of one kind, in input order.

    PlanDiff

    pub(all) struct PlanDiff {
    changes : Array[WindowChange]
    added_windows : Int
    removed_windows : Int
    modified_windows : Int
    new_overlaps : Array[Overlap]
    resolved_overlaps : Int
    new_freeze_conflicts : Array[FreezeConflict]
    resolved_freeze_conflicts : Int
    }

    The differences between two revisions of a plan.

    new_overlaps and new_freeze_conflicts carry the pairs the new revision introduces; resolved_overlaps and resolved_freeze_conflicts count the pairs the old revision had and the new one does not. A pair that overlaps in both revisions is reported by neither, because revision review is about change, not about the state of the world.

    PlanDiff::is_empty

    fn PlanDiff::is_empty(self : PlanDiff) -> Bool

    True when the two revisions are identical: no record changed and no conflict appeared or disappeared.

    PlanSummary

    pub(all) struct PlanSummary {
    changes : Int
    freezes : Int
    blocked_minutes : Int
    change_minutes : Int
    low_risk : Int
    medium_risk : Int
    high_risk : Int
    earliest : Int?
    latest : Int?
    span_minutes : Int
    peak : PeakConcurrency?
    services : Array[ServiceSummary]
    }

    Everything a single glance should tell about a plan.

    blocked_minutes is calendar time (overlaps counted once), while change_minutes is workload (every change counted on its own), so the two differ exactly by the overlap inside the plan.

    Policy

    pub(all) struct Policy {
    max_concurrent : Int
    max_change_minutes : Int
    min_dependency_slack_minutes : Int
    forbid_high_risk_overlap : Bool
    max_high_risk_per_day : Int
    forbid_freeze_conflicts : Bool
    require_service : Bool
    }

    Thresholds and flags applied on top of an audit.

    A limit of 0 means "unlimited", and a flag set to false disables the rule it belongs to. Policy::relaxed() therefore reports nothing at all.

    max_change_minutes measures each change on its own and never counts freezes; the freeze rules are forbid_freeze_conflicts and, indirectly, require_service only for changes.

    Policy::describe

    fn Policy::describe(self : Policy) -> String

    Render every setting as one key = value line, in declaration order.

    Booleans render as true or false, a limit of 0 renders as unlimited, and the result has no trailing newline.

    Policy::relaxed

    fn Policy::relaxed() -> Policy

    A policy that reports nothing: every limit is off, every flag is false. Use it as the neutral element from which a caller builds its own thresholds.

    Policy::standard

    fn Policy::standard() -> Policy

    The default policy: every numeric limit is off and an unnamed service is accepted, but a change may not run inside a declared freeze.

    forbid_freeze_conflicts is the only rule that is on, because a change running inside a freeze is a contradiction rather than a tuning choice.

    Policy::strict

    fn Policy::strict() -> Policy

    A tight policy for a fragile service: two changes at a time, no change longer than two hours, thirty minutes of slack after every dependency, high risk changes that never overlap, at most two high risk changes a day, no change inside a freeze and no change without a named service.

    PolicyReport

    pub(all) struct PolicyReport {
    plan : Plan
    audit : AuditReport
    policy : Policy
    violations : Array[PolicyViolation]
    }

    An audit together with the policy it was judged against and every breach of that policy.

    PolicyReport::error_count

    fn PolicyReport::error_count(self : PolicyReport) -> Int

    Policy errors plus the errors the underlying audit found.

    PolicyReport::has_errors

    fn PolicyReport::has_errors(self : PolicyReport) -> Bool

    True when either the audit or the policy found an error.

    PolicyReport::status

    fn PolicyReport::status(self : PolicyReport) -> String

    FAIL when there is at least one error, PASS otherwise. A report whose only findings are warnings stays at PASS.

    PolicyReport::violation_count

    fn PolicyReport::violation_count(self : PolicyReport) -> Int

    Number of policy violations, regardless of severity.

    PolicyReport::warning_count

    fn PolicyReport::warning_count(self : PolicyReport) -> Int

    Policy warnings plus the warnings the underlying audit found.

    PolicyViolation

    pub(all) struct PolicyViolation {
    code : String
    severity : Severity
    subject : String
    detail : String
    }

    One rule breach: which rule, how bad, about what, and why.

    subject is a window id, a service name or all, depending on the rule.

    PolicyViolation::to_line

    fn PolicyViolation::to_line(self : PolicyViolation) -> String

    Render as error max-concurrent (all): ..., without a line number because a policy speaks about the plan as a whole.

    Risk

    pub(all) enum Risk {
    Low
    Medium
    High
    } derive(Eq)

    Risk level attached to a change window.

    Risk::equal

    fn Risk::equal(Risk, Risk) -> Bool

    Risk::label

    fn Risk::label(self : Risk) -> String

    Lower case label of a risk level.

    Risk::not_equal

    fn Risk::not_equal(x : Risk, y : Risk) -> Bool

    Risk::parse

    fn Risk::parse(text : String) -> Risk?

    Parse low, medium or high. Matching is exact and lower case.

    ScheduleCheck

    pub(all) struct ScheduleCheck {
    candidate : Slot
    allowed : Bool
    reasons : Array[String]
    change_conflicts : Array[SlotConflict]
    freeze_ids : Array[String]
    }

    The verdict for one candidate interval.

    reasons lists the rule codes that fired, in the fixed order no-duration, too-short, blocked-by-change, inside-freeze, unknown-dependency, dependency-order, each at most once. allowed is true exactly when reasons is empty.

    Freezes and changes both block. A freeze never appears in change_conflicts: freezes are reported through freeze_ids.

    ScheduleCheck::to_line

    fn ScheduleCheck::to_line(self : ScheduleCheck) -> String

    Render as <ALLOWED|REJECTED> <start> -> <end> (<duration>), followed by (<reasons joined by ,>) when at least one rule fired.

    For example REJECTED 2026-10-01T22:30 -> 2026-10-01T23:30 (1h) (blocked-by-change,inside-freeze).

    ScheduledChange

    pub(all) struct ScheduledChange {
    id : String
    start : Int
    end : Int
    original_start : Int
    shift_minutes : Int
    }

    One change of the compressed schedule.

    start/end are the planned minutes, original_start the minute the plan asked for and shift_minutes the delay that was added. The shift is never negative: a change is never moved earlier than its original start.

    ScheduledChange::to_line

    fn ScheduledChange::to_line(self : ScheduledChange) -> String

    Render as <id> <start> -> <end> (+<shift>), for example db 2026-10-01T19:00 -> 2026-10-01T20:00 (+0m).

    ServiceSummary

    pub(all) struct ServiceSummary {
    service : String
    changes : Int
    blocked_minutes : Int
    peak_concurrency : Int
    high_risk_changes : Int
    }

    What one service contributes to a plan.

    The empty service name stands for the changes that name no service; it is reported as (none) in text output.

    ServiceSummary::to_line

    fn ServiceSummary::to_line(self : ServiceSummary) -> String

    Render as orders-db: 2 changes, 1h30m blocked, peak 2, 1 high risk change.

    A service with a single change reads 1 change, not 1 changes.

    Severity

    pub(all) enum Severity {
    Error
    Warning
    } derive(Eq)

    Severity of a diagnostic or of a dependency issue.

    Severity::equal

    fn Severity::equal(Severity, Severity) -> Bool

    Severity::label

    fn Severity::label(self : Severity) -> String

    Lower case label: error or warning.

    Severity::not_equal

    fn Severity::not_equal(x : Severity, y : Severity) -> Bool

    Slot

    pub(all) struct Slot {
    start : Int
    end : Int
    }

    A half open [start, end) interval on the minute axis.

    Slot::contains

    fn Slot::contains(self : Slot, minute : Int) -> Bool

    True when the minute lies inside the interval.

    Slot::contains_slot

    fn Slot::contains_slot(self : Slot, other : Slot) -> Bool

    True when other lies completely inside self.

    Slot::duration

    fn Slot::duration(self : Slot) -> Int

    Length of the interval in minutes.

    Slot::intersect

    fn Slot::intersect(self : Slot, other : Slot) -> Slot?

    The shared part of two intervals, or None when they do not overlap.

    Slot::merge_with

    fn Slot::merge_with(self : Slot, other : Slot) -> Slot?

    The smallest interval covering both, or None when a gap stays between them.

    Slot::overlaps

    fn Slot::overlaps(self : Slot, other : Slot) -> Bool

    True when the two intervals share at least one minute.

    Slot::to_line

    fn Slot::to_line(self : Slot) -> String

    Render as 2026-10-01T20:00 -> 2026-10-01T22:00 (2h).

    Slot::touches

    fn Slot::touches(self : Slot, other : Slot) -> Bool

    True when the two intervals overlap or are adjacent, so that no minute can be scheduled between them.

    SlotConflict

    pub(all) struct SlotConflict {
    window_id : String
    minutes : Int
    }

    One planned change the candidate runs into, with the shared minutes.

    SlotConflict::to_line

    fn SlotConflict::to_line(self : SlotConflict) -> String

    Render as api: 45m.

    Window

    pub(all) struct Window {
    id : String
    service : String
    start : Int
    end : Int
    risk : Risk
    depends : Array[String]
    kind : WindowKind
    }

    One maintenance or change window: a half open [start, end) interval with the identifiers of the windows it depends on.

    A freeze uses the same shape but forbids changes instead of describing one, so it carries no risk level and no dependencies.

    Window::duration

    fn Window::duration(self : Window) -> Int

    Length of the window in minutes. Always positive for a parsed window.

    Window::is_change

    fn Window::is_change(self : Window) -> Bool

    True when the record is a planned change.

    Window::is_freeze

    fn Window::is_freeze(self : Window) -> Bool

    True when the record is a change freeze.

    Window::to_freeze_line

    fn Window::to_freeze_line(self : Window) -> String

    Render a freeze as freeze | company | 2026-12-24T00:00 -> 2026-12-27T00:00 | 3d.

    Window::to_line

    fn Window::to_line(self : Window) -> String

    Render as database | orders-db | 2026-10-01T22:00 -> 2026-10-01T23:30 | 1h30m | high | -.

    An empty service or an empty dependency list renders as -.

    WindowChange

    pub(all) struct WindowChange {
    id : String
    kind : ChangeKind
    detail : String
    }

    One record level difference.

    WindowChange::to_line

    fn WindowChange::to_line(self : WindowChange) -> String

    Render as added api (2026-10-01T22:00 -> 2026-10-01T23:00) or as modified api (start 2026-10-01T22:00 -> 2026-10-01T22:15, risk low -> high).

    WindowKind

    pub(all) enum WindowKind {
    Change
    Freeze
    } derive(Eq)

    Kind of record: a planned change, or a period during which no change may run at all.

    WindowKind::equal

    fn WindowKind::equal(WindowKind, WindowKind) -> Bool

    WindowKind::label

    fn WindowKind::label(self : WindowKind) -> String

    Lower case label: change or freeze.

    WindowKind::not_equal

    fn WindowKind::not_equal(x : WindowKind, y : WindowKind) -> Bool

    WindowKind::parse

    fn WindowKind::parse(text : String) -> WindowKind?

    Parse change or freeze. window is accepted as a synonym of change.

    audit

    fn audit(plan : Plan) -> AuditReport

    Audit a plan.

    Finds every pair of windows that share at least one minute, checks every dependency for a missing target, a self reference and a wrong order, and measures the total blocked time.

    A dependency is satisfied when it ends at or before the dependent starts. Ending exactly at the start is accepted but reported as a back-to-back warning, because a change with no slack has no room for overrun.

    Windows that failed to parse are not part of plan.windows and therefore cannot overlap or be depended on; their problems stay in plan.diagnostics, and the report still fails.

    audit_with_policy

    fn audit_with_policy(plan : Plan, policy : Policy) -> PolicyReport

    Judge a plan against a policy.

    The audit is computed exactly once with audit(plan) and stored unchanged, so report.audit is always the same report the plain audit would return; the policy only adds violations beside it.

    blocked_intervals

    fn blocked_intervals(plan : Plan) -> Array[Slot]

    Blocking intervals of every window in the plan, merged and sorted.

    Overlapping windows are counted once, so the result is the real set of minutes during which at least one planned change is running.

    blocked_minutes

    fn blocked_minutes(plan : Plan) -> Int

    Total minutes blocked by the plan, counting overlapping windows once.

    busiest_intervals

    fn busiest_intervals(plan : Plan, min_count : Int) -> Array[Slot]

    Intervals during which at least min_count changes run at the same time, merged and ordered.

    min_count of 1 therefore returns the blocked intervals of the changes, and a higher value narrows that down to the crowded part of the plan.

    can_schedule

    fn can_schedule(plan : Plan, candidate : Slot, depends : Array[String], min_minutes : Int) -> ScheduleCheck

    Decide whether a candidate interval may be added to the plan.

    The rules are applied in a fixed order and each reason is reported at most once:

    • no-duration: the candidate is empty or reversed,
    • too-short: shorter than min_minutes,
    • blocked-by-change: it overlaps a planned change; a change that only touches the candidate is not a conflict,
    • inside-freeze: it overlaps a change freeze,
    • unknown-dependency: one of depends is not a record of the plan,
    • dependency-order: a dependency that exists ends after the candidate starts. Ending exactly at the start is accepted, so a change may run back to back with what it waits for.

    Dependencies are read from the depends argument rather than from a record, because the candidate is not in the plan yet.

    candidate_slot

    fn candidate_slot(start : Int, end : Int) -> Slot

    Build a [start, end) interval, so that callers do not have to name the struct literal of Slot.

    change_minutes

    fn change_minutes(plan : Plan) -> Int

    Total length of every change, counting minutes shared by several changes once per change.

    This is the workload the plan asks for, while blocked_minutes is the calendar time it occupies.

    clip_slot

    fn clip_slot(slot : Slot, from : Int, to : Int) -> Slot?

    Shrink an interval to the part inside [from, to).

    concurrency_at

    fn concurrency_at(plan : Plan, minute : Int) -> Int

    Number of changes covering the minute.

    Freezes are not changes, so they never raise this count.

    csv_escape

    fn csv_escape(text : String) -> String

    Quote a field when it needs it.

    A field needs quoting when it holds a comma, a double quote, a newline or a carriage return; inside the quotes every double quote is doubled.

    dependencies_of

    fn dependencies_of(plan : Plan, id : String) -> Array[String]

    Dependencies declared by one record, or an empty array when the id is not in the plan.

    dependency_depth

    fn dependency_depth(plan : Plan, id : String) -> Int

    Length of the longest dependency chain below an id, 0 when it has none.

    A cycle cannot extend a chain: the walk stops when it meets an id that is already on the path, and find_cycles reports the cycle itself.

    dependents_of

    fn dependents_of(plan : Plan, id : String) -> Array[String]

    Ids that declare a dependency on the given id, in plan order.

    diff_plans

    fn diff_plans(before : Plan, after : Plan) -> PlanDiff

    Compare two revisions of a plan.

    The records are matched by id, so a record present in both plans with any different field is one modification. Removals come first, then additions, then modifications, each in the order of the plan they come from.

    earliest_schedule

    fn earliest_schedule(plan : Plan) -> Array[ScheduledChange]?

    Compress the whole plan into the earliest dependency aware schedule.

    Changes are processed in execution_order, and a change starts at the later of its original start and the scheduled end of every dependency that exists in the plan and was already scheduled. Duration and order are preserved, and nothing is moved earlier, so the result is a valid schedule whenever the original plan is acyclic.

    None when the plan cannot be ordered, that is when it contains a cycle.

    earliest_start

    fn earliest_start(plan : Plan, candidate : Slot, min_minutes : Int, search_minutes : Int) -> Int?

    Earliest minute at or after candidate.start where an interval of the same duration fits without overlapping a change or a freeze.

    The walk never steps minute by minute across a blocked stretch: when the current position is blocked it jumps to the end of the blocking interval, so a long freeze is skipped in one step. The last position tried is candidate.start + search_minutes.

    None when the duration is not positive, when min_minutes is greater than the duration, when search_minutes is negative, or when nothing fits inside the search range. A zero search_minutes therefore asks the single question "does the candidate fit where it is?".

    execution_order

    fn execution_order(plan : Plan) -> Array[String]?

    An order in which the changes can run so that every dependency comes first.

    Ties are broken by start minute and then by id, so the order is a pure function of the plan. Dependencies on missing ids are ignored, and None means the plan contains a cycle.

    find_cycles

    fn find_cycles(plan : Plan) -> Array[Array[String]]

    Every dependency cycle in the plan, each one once.

    A cycle is rendered as the ids in walk order starting from its smallest id, and the list is ordered by that first id. Cyclic plans cannot be ordered, so execution_order returns None for them.

    find_service

    fn find_service(summary : PlanSummary, service : String) -> ServiceSummary?

    The summary of one service, or None when the name is not in the plan.

    format_duration

    fn format_duration(minutes : Int) -> String

    Render a duration in minutes as 45m, 2h or 1h30m. The value is treated as a magnitude, and 0 renders as 0m.

    format_instant

    fn format_instant(minutes : Int) -> String

    Render minutes since 1970-01-01T00:00 as YYYY-MM-DDTHH:MM.

    This is the inverse of parse_instant for every instant it accepts. Negative input is clamped to 1970-01-01T00:00, because instants before the epoch are outside the supported range.

    freeze_conflicts

    fn freeze_conflicts(plan : Plan) -> Array[FreezeConflict]

    Every pair of a planned change and a change freeze that share at least one minute.

    A freeze forbids changes, so each pair is a conflict. Pairs are produced in freeze order, then in change order, which keeps the report reproducible.

    freeze_minutes

    fn freeze_minutes(plan : Plan) -> Int

    Total length of every freeze, counting shared minutes once per freeze.

    freezes_hit_by

    fn freezes_hit_by(plan : Plan, candidate : Slot) -> Array[Window]

    Freezes a candidate interval would run into, ordered by freeze start.

    This is the freeze half of can_schedule; it is exported separately so that callers can explain a refusal without running a full audit.

    graph_leaves

    fn graph_leaves(plan : Plan) -> Array[String]

    Changes that no other change waits for, in plan order.

    graph_roots

    fn graph_roots(plan : Plan) -> Array[String]

    Changes that declare no dependency on a record that exists, in plan order.

    A dependency on a missing id is ignored here: the audit reports it, and the change still has nothing to wait for.

    intersect_slots

    fn intersect_slots(left : Array[Slot], right : Array[Slot]) -> Array[Slot]

    Shared parts of two interval sets, both merged first.

    invert_slots

    fn invert_slots(slots : Array[Slot], from : Int, to : Int) -> Array[Slot]

    Parts of [from, to) that no interval covers.

    Returns an empty array when to <= from.

    json_escape

    fn json_escape(text : String) -> String

    Escape a string for the body of a JSON string.

    " becomes \", \ becomes \\, a newline \n, a carriage return \r, a tab \t, a backspace \b and a form feed \f. Any other character below 0x20 becomes \u00XX with two lower case hexadecimal digits. Everything from 0x20 upwards passes through unchanged, so UTF-8 text such as 订单 stays readable and is not turned into \u escapes.

    load_profile

    fn load_profile(plan : Plan, from : Int, to : Int, step_minutes : Int) -> Array[LoadPoint]

    Sample the number of running changes every step_minutes from from while the sample stays before to.

    A step of zero or less produces no samples. Sampling stops after 10000 points so that a long horizon with a tiny step cannot build an unbounded array; the caller keeps control of the cost by choosing the step.

    max_concurrency_in

    fn max_concurrency_in(plan : Plan, slot : Slot) -> Int

    Highest number of changes running at any minute inside the interval.

    merge_slots

    fn merge_slots(slots : Array[Slot]) -> Array[Slot]

    Merge overlapping or touching intervals into a minimal ordered list.

    next_slots

    fn next_slots(plan : Plan, from : Int, to : Int, min_minutes : Int, limit : Int) -> Array[Slot]

    The first limit free slots of safe_slots(plan, from, to, min_minutes), in order.

    The horizon is not extended to fill the limit: fewer slots are returned when the plan leaves less room. limit <= 0 yields an empty array.

    parse_instant

    fn parse_instant(text : String) -> Int?

    Parse YYYY-MM-DDTHH:MM into minutes since 1970-01-01T00:00.

    A single space is accepted where the T belongs. Everything else is rejected: other lengths, separators, a year outside 1970-2199, an impossible date such as 2026-02-30T10:00, 24 as hour and 60 as minute.

    parse_plan

    fn parse_plan(source : String) -> Plan

    Parse a plan from its text form.

    A record that produces an error is dropped from plan.windows; a record that only produces warnings is kept. Every problem is reported in plan.diagnostics with the 1-based line number it came from.

    peak_concurrency

    fn peak_concurrency(plan : Plan) -> PeakConcurrency?

    Highest number of simultaneous changes, or None for a plan without changes.

    The maximum can only be reached at the start of some change, so the search walks the start minutes and breaks ties towards the earliest one. The answer is therefore deterministic.

    render_cycle

    fn render_cycle(cycle : Array[String]) -> String

    Render one cycle as a -> b -> a.

    render_dependency_issues_csv

    fn render_dependency_issues_csv(report : AuditReport) -> String

    Dependency issues as CSV, with the columns window,dependency,problem,severity,detail.

    render_diagnostics

    fn render_diagnostics(diagnostics : Array[Diagnostic]) -> String

    Render diagnostics one per line, without indentation. An empty list renders as an empty string.

    render_diff

    fn render_diff(diff : PlanDiff) -> String

    Render a diff as a stable text block.

    The text ends with exactly one newline.

    render_execution_order

    fn render_execution_order(plan : Plan, order : Array[String]?) -> String

    Render an execution order as 1. database lines, or (cyclic) when the plan cannot be ordered.

    render_freeze_conflicts_csv

    fn render_freeze_conflicts_csv(report : AuditReport) -> String

    Freeze conflicts as CSV, with the columns window,freeze,minutes.

    render_overlaps_csv

    fn render_overlaps_csv(report : AuditReport) -> String

    The overlaps of an audit as CSV, with the four columns first,second,minutes,impact.

    render_plan_json

    fn render_plan_json(plan : Plan) -> String

    Render a plan as JSON: an object with windows and diagnostics.

    Windows appear in sort_windows_by_start order, which is stable for equal start minutes, so the same plan always renders the same bytes. Diagnostics keep their input order, because their line numbers are only meaningful in that order.

    render_policy_report

    fn render_policy_report(report : PolicyReport) -> String

    Render a policy report.

    The header is followed by the untouched audit report, then by the policy section: the two-space-indented violations, or (none), and finally the policy: <status> line. The string ends with exactly one newline.

    render_report

    fn render_report(report : AuditReport) -> String

    Render a whole audit as a text report.

    The summary carries the counts, the blocked time and the status line: FAIL when at least one error was found, PASS otherwise. Warnings alone keep the status at PASS.

    render_report_json

    fn render_report_json(report : AuditReport) -> String

    Render an audit as JSON.

    The summary fields come first, then the collections. changes holds the planned changes and freezes the change freezes, each sorted by start; the remaining arrays keep the order the audit produced them in, so an overlap, a conflict or an issue stays attributable to the same pair of records.

    render_report_markdown

    fn render_report_markdown(report : AuditReport) -> String

    Render a whole audit as Markdown.

    The summary is a bullet list, then one section per collection. A section without rows renders the single line none so a reader can tell "nothing found" from "section dropped". The text ends with exactly one newline.

    render_schedule

    fn render_schedule(schedule : Array[ScheduledChange]) -> String

    Render a schedule that is already known to be acyclic, one two space indented line per change.

    An empty schedule renders as Earliest dependency aware schedule (0 changes)\n (no changes)\n.

    render_schedule_result

    fn render_schedule_result(schedule : Array[ScheduledChange]?) -> String

    Render the result of earliest_schedule, including the cyclic case that the schedule array itself cannot express.

    • a schedule: Earliest dependency aware schedule (<n> changes) and one indented line per change,
    • None: Earliest dependency aware schedule and (cyclic),
    • an empty schedule: Earliest dependency aware schedule (0 changes) and (no changes).

    The plan is not needed: earliest_schedule already turned it into the schedule, and the cyclic case carries no per change detail. The text always ends with exactly one newline.

    render_slots

    fn render_slots(slots : Array[Slot]) -> String

    Render the slots returned by safe_slots, one per line with its length.

    render_slots_csv

    fn render_slots_csv(slots : Array[Slot]) -> String

    Safe slots as CSV, with the columns start,end,minutes.

    render_slots_json

    fn render_slots_json(slots : Array[Slot]) -> String

    Render safe slots as a JSON array of {start, end, minutes} objects.

    The input order is preserved: safe_slots already returns the intervals in chronological order, and re-sorting here would hide a caller's own ordering.

    render_slots_markdown

    fn render_slots_markdown(slots : Array[Slot]) -> String

    Render safe slots as a Markdown table, or none when there are none.

    The text ends with exactly one newline.

    render_summary

    fn render_summary(summary : PlanSummary) -> String

    Render a summary as a stable text block.

    The shape is fixed: a block of key: value lines, then a SERVICES section with one two-space-indented line per service or (none). The text ends with exactly one newline.

    render_timeline

    fn render_timeline(plan : Plan, from : Int, to : Int, columns : Int) -> String

    Render the timeline of [from, to) with columns cells per row.

    cell_minutes is the ceiling of the horizon divided by the column count, so the last cell may reach past to; the header prints the resolution so the rounding is never a surprise. Records are ordered by start minute.

    When the horizon is empty (to <= from) or columns <= 0, the result is the single line Timeline (empty horizon) with one trailing newline.

    render_violations

    fn render_violations(violations : Array[PolicyViolation]) -> String

    Render violations one per line, without indentation. An empty list renders as an empty string.

    render_windows_csv

    fn render_windows_csv(plan : Plan) -> String

    Every record of the plan as CSV, ordered by start minute.

    Columns are id,service,kind,start,end,minutes,risk,depends; a dependency list is joined with |, because a comma inside a cell would be ambiguous even when quoted.

    render_windows_markdown

    fn render_windows_markdown(plan : Plan) -> String

    A table of every record of the plan, ordered by start minute.

    An empty service and an empty dependency list render as -.

    safe_slots

    fn safe_slots(plan : Plan, from : Int, to : Int, min_minutes : Int) -> Array[Slot]

    Slots inside [from, to) where a new change can run, each at least min_minutes long.

    Windows are clipped to the horizon, so a window that starts before from still blocks the beginning of the horizon and a window that ends after to blocks the end. Returns an empty array when to <= from.

    The result ignores dependencies: a free slot is free of the windows in the plan, not necessarily of the changes that would have to precede a new one.

    schedule_check_line

    fn schedule_check_line(check : ScheduleCheck) -> String

    Free function alias of ScheduleCheck::to_line.

    service_concurrency_at

    fn service_concurrency_at(plan : Plan, service : String, minute : Int) -> Int

    Number of changes of one service covering the minute. An empty service name matches the changes that have no service.

    slack_minutes

    fn slack_minutes(plan : Plan, id : String) -> Int?

    Smallest slack, in minutes, that id has against its dependencies: change.start - dependency.end.

    A negative value means the dependency outlives the start of the dependent, 0 means back to back. None when the record is not in the plan or when none of its dependencies exists, because there is then nothing to measure.

    slot_at

    fn slot_at(slots : Array[Slot], minute : Int) -> Slot?

    First interval that contains the minute, or None.

    sort_slots

    fn sort_slots(slots : Array[Slot]) -> Array[Slot]

    Copy intervals ordered by start minute, then by end minute.

    subtract_slots

    fn subtract_slots(left : Array[Slot], right : Array[Slot]) -> Array[Slot]

    Interval set difference: every part of left that right does not cover.

    summarize

    fn summarize(plan : Plan) -> PlanSummary

    Summarise a plan.

    Services are ordered by name with code point comparison, so the empty service sorts first and the output never depends on the input order.

    total_duration

    fn total_duration(slots : Array[Slot]) -> Int

    Sum of the lengths of the given intervals.

    Call it on the result of merge_slots to measure a union; on raw intervals it counts shared minutes as often as they appear.

    transitive_dependencies

    fn transitive_dependencies(plan : Plan, id : String) -> Array[String]

    Every id reachable through dependencies, sorted and without duplicates.

    Ids that are not in the plan are reported by the audit as missing-dependency and are skipped here.