Documentation
¶
Overview ¶
Package planner is L3 of the SigComply CLI: reads the project config plus the selected framework plus the registries and produces a fully resolved RunPlan — slot bindings, effective parameters, resolved exceptions, derived period. The planner does no external I/O; planning errors exit with code 3.
See docs/architecture/02-layers.md for the full layer contract.
Index ¶
- Constants
- func CadenceInterval(cadence string) time.Duration
- func DueCadences(lastSuccess map[string]time.Time, now time.Time) []string
- func DueReason(cadence string, state *core.PolicyState, now time.Time) string
- func ExperimentalKeyWarnings(cfg *spec.ProjectConfig) []string
- func IsDue(cadence string, state *core.PolicyState, now time.Time) bool
- func IsValidCadence(s string) bool
- func NextDueAt(cadence string, lastPass time.Time) time.Time
- func PeriodTime(cfg *spec.PeriodConfig, commit, now time.Time) time.Time
- func RiskWarnings(cfg *spec.ProjectConfig, controls []core.Control, periodStart time.Time) []string
- func RosterWarnings(cfg *spec.ProjectConfig, set *registry.Set) []string
- func ScheduledCadences() []string
- func SourceConfigWarnings(cfg *spec.ProjectConfig) []string
- func SplitCommaList(s string) []string
- func ValidateCadence(s string) error
- func VendorWarnings(cfg *spec.ProjectConfig) []string
- type Binding
- type CoverageGap
- type Exception
- type Filter
- type Input
- type Period
- type PlannedPolicy
- type RosterLink
- type RunPlan
- type SourceCaveatWarning
Constants ¶
const RiskReviewMaxAge = 18 * 30 * 24 * time.Hour
RiskReviewMaxAge is how long a risk may go un-reassessed before the register is reported as stale.
This is a backstop heuristic, not the standard's requirement. ISO/IEC 27001 8.2 says assessments happen "at planned intervals or when significant changes are proposed or occur" and defines no interval at all — the interval is the organization's to set, and the nonconformity is failing to meet the one they set themselves. The CLI cannot know that number, so it does not pretend to: it warns only at a threshold nobody's declared cycle would legitimately exceed.
Eighteen months rather than twelve for exactly that reason. An annual cycle is the norm, and twelve would warn on every honest register during the weeks between the anniversary and the review actually happening. Eighteen leaves that alone and still catches a register nobody has revisited in three years — the case this exists to find, and a documented lead-auditor finding.
Variables ¶
This section is empty.
Functions ¶
func CadenceInterval ¶ added in v0.21.0
CadenceInterval returns the minimum elapsed time after a successful run before a cadence is due again. Intervals are deliberately shorter than their nominal values to absorb CI cron drift: GitHub Actions and GitLab CI commonly fire scheduled jobs 5–15 minutes late under load, and a strict 24h gate would silently skip days when drift crosses midnight. A 1h slack per cadence is enough for both platforms in practice.
continuous and hourly return 0 — the caller (DueCadences) treats them as "always due" in any scheduled run, since the scheduled job runs at most once per day and sub-daily cadences effectively fire on every run.
For `every:<duration>` cadences the returned interval is the parsed duration verbatim (no slack — the user picked the number). An unparseable `every:` value returns 0, which the planner treats as "always due" so a misconfigured cadence is loud rather than silently disabling the policy.
func DueCadences ¶ added in v0.21.0
DueCadences returns the set of NAMED cadences that should fire in a scheduled run given the last-success-by-cadence map and the current time. This function is retained for the orchestrator's legacy --cadences filter resolution. The per-policy due decision (which is now authoritative) is in IsDue above.
A cadence is due when (now - last_success) >= CadenceInterval. A cadence absent from the map or with a zero timestamp is treated as never-run and therefore due. continuous and hourly are always due. `every:<duration>` cadences are NOT considered here — they have no canonical bucket; each policy with an every-cadence drives its own due decision via IsDue.
The returned slice is in the canonical scheduledCadences order so callers iterating it observe deterministic behavior across runs.
func DueReason ¶ added in v0.21.0
DueReason returns a short human-readable reason string for why a policy is (or isn't) due at `now` under the given cadence and state. Powers the carry-forward SkipReason and the run-level "skipped because…" diagnostics. Reasons are deterministic given (cadence, state, now) and free of resource identifiers.
func ExperimentalKeyWarnings ¶ added in v0.89.0
func ExperimentalKeyWarnings(cfg *spec.ProjectConfig) []string
ExperimentalKeyWarnings reports keys directly under experimental: that no feature reads. A misspelled `scopee:` silently turns off the SCOPE INCOMPLETE gate, and `vendor:` silently drops the vendor fan-out, with nothing in the output to say so.
Warning, never error, for the reason experimental: exists: a newer config may carry a key this CLI predates, and that must not fail the run.
func IsDue ¶ added in v0.21.0
IsDue reports whether a policy with the given cadence and prior state is due at `now`. The decision composes three layers:
- First run (zero LastRunAt) → due.
- on_fail_retry: prior status was not pass → due regardless of cadence.
- Cadence elapsed: now - LastPassAt >= CadenceInterval(cadence) → due.
Content-hash invalidation (LastPolicyHash mismatch with the current core.PolicyContentHash — which covers the policy's pass_when body and its referenced schema digests) is handled by decideEvaluation outside this function, so the same logic also drives why-is-this-skipped introspection without needing the current hash.
func IsValidCadence ¶ added in v0.21.0
IsValidCadence reports whether s is a recognized cadence — either a named cadence (continuous, hourly, …, annual) or a parseable `every:<duration>` value. Used by the spec validator and the planner's lint warnings.
func NextDueAt ¶ added in v0.21.0
NextDueAt computes the wall-clock time after which a policy with the given cadence and last-pass timestamp is due to be re- evaluated. Returns the zero time for "continuous" / "hourly" and for never-run policies — both translate to "always due" in the planner.
For named cadences NextDueAt = lastPass + CadenceInterval (with the embedded cron-drift slack). For `every:<duration>` cadences NextDueAt = lastPass + duration exactly.
func PeriodTime ¶ added in v0.78.0
PeriodTime selects the clock every period derivation in a run is measured on, honoring period.time_basis. Unset (the default) and "wall_clock" tie the audit window to the run's start; "commit", only when set explicitly, ties it to the HEAD commit's timestamp so a replayed run reproduces the same period.
The run clock is the default because a dedicated compliance repository rarely gets commits: under the commit basis its daily scheduled run would keep evaluating, and pointing uploads at, a period that closed months ago.
A zero now falls back to the commit time: a caller with no run clock must not silently derive a year-1 period.
func RiskWarnings ¶ added in v0.82.0
RiskWarnings reports the non-fatal findings about experimental.risks: unrecognized subkeys, risks naming a control the framework does not have, and risks nobody has reassessed inside the review window.
The second is the one that matters. A risk names its treating controls as free text, and a typo — "A.8.07" for "A.8.7" — simply fails to join in the Statement of Applicability. The row renders with no risk beside it and nothing says the operator meant otherwise, which is the same silent-typo failure that `experimental.roster.aliases` reports rather than ignores.
Warning, never error. Everything fatal about the block already failed at config load; what is left is advice about content, and a register is the operator's own document. A stale assessment is a finding for their auditor, not a reason to fail their build.
Silent when the register is undeclared: a project that never adopted experimental.risks is not missing anything.
func RosterWarnings ¶ added in v0.68.0
func RosterWarnings(cfg *spec.ProjectConfig, set *registry.Set) []string
RosterWarnings returns the non-fatal findings about experimental.roster for the orchestrator to log: unrecognized subkeys (tolerated, per the experimental: hatch) and a roster designated for a framework none of whose policies has a roster slot. Returns nil when the block is absent or malformed (Plan reports the latter as an error).
func ScheduledCadences ¶ added in v0.21.0
func ScheduledCadences() []string
ScheduledCadences returns a copy of the canonical named-cadence list, for callers that need to enumerate every scheduled-mode cadence (e.g. to seed an empty state file or to validate config). Excludes `every:<duration>` forms by design.
func SourceConfigWarnings ¶ added in v0.84.0
func SourceConfigWarnings(cfg *spec.ProjectConfig) []string
SourceConfigWarnings reports keys under a `sources:` entry that the source does not read, and declared string keys whose value is not a string.
The project config loader runs with KnownFields(true), which catches a typo anywhere else in the file. It stops at the source key: the inner bag is a map[string]any by design, so `tenat_id`, or `role_arn` on an Azure source, produces no output at all — the run proceeds with the value silently ignored, and the operator's mental model of what was audited is wrong in a way nothing corrects.
The type check is the other half, and the reason a key list alone would not be enough: sources.StringOpt returns "" for both a missing key and a wrong-typed one, so `project_id: 12345` (an unquoted YAML integer) reads as absent. That produces "project_id required" if the key is mandatory, and silence if it is not.
Warning, never error. The `experimental:` hatch exists precisely so a newer config can carry keys an older CLI does not know, and the same tolerance belongs here: failing the run would make every forward- compatible config an outage. A source that declared no keys is never warned about, so a project-local plugin is unaffected.
func SplitCommaList ¶
SplitCommaList is a small helper for the orchestrator: turn a comma-separated CLI flag value into a trimmed list.
func ValidateCadence ¶ added in v0.21.0
ValidateCadence returns nil iff s is recognized. For invalid input the error message lists the valid forms.
func VendorWarnings ¶ added in v0.74.0
func VendorWarnings(cfg *spec.ProjectConfig) []string
VendorWarnings reports the non-fatal findings about experimental.vendors: unrecognized subkeys, and configured sources that no register entry claims.
The second is the point. The register is a declaration with nothing to diff it against, so a vendor the operator leaves out is invisible — the one gap the vendor policies cannot close, because they only evaluate what was declared. But a *configured source* is a third party the project demonstrably depends on, named in the operator's own config. Asserting that each one appears in the register is an observed baseline checking a declared one.
It discovers nothing and widens nothing: it reads the sources block the operator already wrote, so it stays entirely inside the chosen estate. Warning, never error — deciding what belongs in the register is the operator's call, and a source may legitimately be out of scope for third-party risk.
Silent when the register is undeclared: a project that never adopted experimental.vendors is not missing anything.
Types ¶
type Binding ¶
type Binding struct {
SourceID string
AcceptedTypes []string
CatalogID string // non-empty only for manual sources (e.g. manual.pdf:access_review_quarterly)
SlotParams map[string]any
Period *Period
}
Binding is one resolved (source instance, optional slot params) pair. Multiple bindings on one slot mean "union the records from all of them" — the evaluator merges them per slot.
AcceptedTypes is the intersection of the slot's Accepts list and the source's Emits() list, computed at plan time. The collector passes it through SlotRequest.AcceptedTypes so the plugin knows which of its emitted types the slot will accept. A binding always has at least one AcceptedTypes entry (the planner rejects a binding whose intersection is empty).
Period is non-nil only for manual bindings, and carries the cadence-aligned window this entry's evidence is filed under — CadencePeriod's answer, not the run's. The collector stamps its four fields onto the slot request together, so the folder, the temporal window and the prior-period comparison can never disagree.
type CoverageGap ¶ added in v0.30.0
type CoverageGap struct {
Slot string // the unbound required slot
Accepts []string // evidence types the slot accepts
Source string // configured source ID emitting a sibling version
SourceEmits []string // the sibling (family-matching but unaccepted) types it emits
}
CoverageGap flags a required slot that resolved to zero bindings even though a configured source emits a *sibling version* of a type the slot accepts — for example, the slot accepts directory_user.v2 but a configured okta source emits directory_user (v1). The slot cannot bind such a source, so the policy is silently skipped at evaluation (requiredSlotsPopulated → StatusSkip) and disappears from the compliance-score denominator, hiding the gap. The planner records the near-miss so the orchestrator can surface it as an explicit warning.
This is deliberately NOT a hard error: a version may genuinely be unable to answer a policy (the admin-MFA check needs v2-only fields that v1 cannot supply), so the legitimate fix is sometimes "leave it skipped", sometimes "extend accepts:", and sometimes "wire a v2-capable source" — the operator decides.
type Exception ¶
type Exception struct {
State core.PolicyStatus // StatusWaived or StatusNA
Reason string
ResourceID string
ResourcePattern string
ApprovedBy string
ApprovedAt string
ExpiresAt string
}
Exception is the resolved waiver / N/A declaration applied to a policy. The evaluator uses State to short-circuit (na) or re-classify failures (waived).
type Filter ¶
type Filter struct {
Policies []string
Controls []string
Cadence string
OnPush bool
Cadences []string
}
Filter narrows the set of policies the plan covers. The five fields are mutually exclusive — passing more than one yields an error. Filter{} means "every policy in the framework."
Cadences is the canonical "fire when these cadences are due" filter — set-intersection against core.PolicyCadences(policy). It is the shape the orchestrator's PR and Scheduled modes use. Cadence and OnPush remain as legacy single-axis shortcuts; they translate to single-element Cadences sets internally.
When any field is set, all matching policies receive ShouldEvaluate=true regardless of cadence state. The filter is the operator's explicit override — "I want these to run now" — and trumps the planner's cadence-elapsed gate.
func (*Filter) IsExplicit ¶ added in v0.21.0
IsExplicit returns true when at least one filter axis is set. An explicit filter forces ShouldEvaluate=true for every matching policy — the user said "run these now."
type Input ¶
type Input struct {
Config *spec.ProjectConfig
Registries *registry.Set
CommitTime time.Time
Now time.Time
Filter Filter
PolicyStates map[string]*core.PolicyState
SchemaDigests map[string]string
}
Input collects everything the planner needs to compute a Plan. CommitTime and Now are both handed to the planner as they are; the project's period.time_basis picks between them (see PeriodTime), so no caller has to remember which clock a period is measured on.
PolicyStates is keyed by policy_id and provides the per-policy scheduling state read from the vault. A nil map disables cadence gating: every policy in the framework will have ShouldEvaluate set to true. A non-nil map enables gating: each policy's ShouldEvaluate decision composes its cadence, prior status, content-hash, and the operator's filter.
SchemaDigests maps evidence-type IDs to their JSON Schema digests at plan time. Used by PolicyContentHash so a schema bump invalidates prior evaluations of every policy referencing the bumped schema. Pass nil to disable schema-hash discrimination (the policy-spec content — including the full pass_when body — still contributes to the hash).
type Period ¶
type Period struct {
ID string
PriorID string
Start time.Time
End time.Time
TimeBasis string // "commit" or "wall_clock"
}
Period is the audit window this run belongs to. Computed from (commit_time, fiscal_calendar) — see docs/architecture/01- conceptual-model.md §Period.
PriorID is the ID of the immediately preceding period under the same calendar (e.g. 2026-Q2 → 2026-Q1, FY2026 → FY2025). Empty when no prior period exists in the configured custom calendar. Plugins use it to compare against the prior period's evidence — currently the manual.pdf plugin reads it to detect copy-paste of last period's PDF.
func CadencePeriod ¶ added in v0.78.0
CadencePeriod computes the audit window a policy of the given cadence files its manual evidence under. It is the folder scheme documented in docs/configuration.md §Manual evidence, and it is deliberately NOT the run's period: an annual attestation uploaded in January has to satisfy a December run, which it cannot do from a folder that turns over every quarter.
The run's own period is unaffected — the vault run root, manifest.json, summary.json, the cloud payload and PolicyState.LastPeriodID all keep DerivePeriod's answer. Only the customer's upload folder, and the temporal window checked against it, follow the cadence.
The rule, by fiscal_calendar.type:
cadence calendar_quarter fiscal_year custom daily 2026-01-15 2026-01-15 run period weekly 2026-W03 2026-W03 run period monthly 2026-01 2026-01 run period quarterly 2026-Q1 2026-Q1 run period annual 2026 FY2026 run period
A fiscal year is the project's declared annual window, so the annual cadence honors it. Nothing in the config subdivides a fiscal year, so shorter cadences stay calendar-aligned — which also keeps them identical to the keys the Evidence SPA derives client-side in src/lib/period.ts. A custom calendar names its own windows and cannot be subdivided at all, and continuous/hourly/every:<duration> name no calendar window, so both keep the run's period rather than being rounded into one.
func DerivePeriod ¶
DerivePeriod computes the run's audit period for a timestamp under the project's fiscal_calendar configuration. The function is pure: the only inputs are the configured calendar and the time; the only output is a stamped Period. Pass the time through PeriodTime so the project's time_basis is honored. See docs/architecture/01- conceptual-model.md §Period for the model.
type PlannedPolicy ¶
type PlannedPolicy struct {
Spec core.Policy
Cadence string
Parameters map[string]any
Bindings map[string][]Binding
// Exceptions holds every live exception for the policy, in declared
// order. When one has no resource scope it is the only entry: a
// whole-policy waiver or N/A already decides the result, so scoped
// entries beside it would never be consulted. Empty when none apply.
Exceptions []*Exception
ShouldEvaluate bool
SkipReason string // empty when ShouldEvaluate is true
// EvidenceModeOverridden is true when the project config's
// policy_overrides section changed this policy's evidence_mode from
// what the framework spec declared. Copied onto the PolicyResult by
// the evaluator and persisted in result.json, so an auditor can see
// which controls are running in an overridden mode — nearly always
// an automated check downgraded to a document upload.
EvidenceModeOverridden bool
// CoverageGaps lists version-skew near-misses: required slots that
// resolved to zero bindings while a configured source emits a
// different *version* of a type the slot accepts. Such policies are
// silently skipped at evaluation and drop out of the compliance
// score; the orchestrator warns the operator. Empty for the common
// case. See CoverageGap.
CoverageGaps []CoverageGap
// SourceCaveats lists the bound sources that declared they cannot
// really observe a field this policy reads — e.g. an identity source
// with no per-user MFA API bound to an MFA policy. Advisory only: the
// policy plans and evaluates unchanged, and nothing here reaches the
// wire. The orchestrator warns so an operator learns at plan time that
// a control is graded partly on a value nobody measured. Empty for the
// common case. See SourceCaveatWarning.
SourceCaveats []SourceCaveatWarning
// UnboundRequiredSlots names the required slots that resolved to zero
// bindings, sorted. Unlike CoverageGaps (which reports only the
// version-skew near-miss) this is the plain "no configured source
// emits what this slot accepts" case — the policy will be skipped at
// evaluation and will vanish from the compliance-score denominator.
// Consumed by the scope report so an unevaluated control is
// distinguishable from a passing one. Empty for the common case.
UnboundRequiredSlots []string
// Roster carries the project's roster declarations for policies that
// have a roster-role slot; nil otherwise. The evaluator uses it to
// resolve account.* fields (aliases, non-human accounts).
Roster *RosterLink
PriorState *core.PolicyState
ContentHash string
// StateHash is ContentHash plus the policy's effective project
// configuration: resolved parameters, cadence, evidence mode and
// catalog entry, bound source IDs per slot, and live exceptions. It is
// what the cadence gate compares with PriorState.LastPolicyHash and
// what advancePolicyStates records, so a config edit invalidates a
// carried pass. It stays in the vault's state shard; ContentHash, which
// covers the policy spec only, is the one that goes on the wire.
StateHash string
}
PlannedPolicy is one policy with everything needed to execute it already resolved. Parameters and exceptions are flattened to their effective values so the evaluator never goes back to the config.
ShouldEvaluate is the per-policy gate decided by the planner from the policy's cadence, its on_fail_retry state, the content-hash of the current policy bundle vs the prior run, and the operator's filter overrides. When false, the orchestrator emits a carry- forward result.json that references PriorState.LastEnvelopeRef.
PriorState is the previously-persisted state for this policy, or nil for never-evaluated policies. The orchestrator uses it for carry-forward emission and for the run-summary's freshness view.
ContentHash is the SHA-256 of the canonicalized policy spec + its referenced evidence-type schemas at plan time. Compared against PriorState.LastPolicyHash to detect bundle updates that invalidate prior evaluations.
func (*PlannedPolicy) WholePolicyException ¶ added in v0.88.6
func (pp *PlannedPolicy) WholePolicyException() *Exception
WholePolicyException returns the live exception with no resource scope, or nil. The planner guarantees it is then the only exception.
type RosterLink ¶ added in v0.68.0
type RosterLink struct {
// Source is the designated roster source ID.
Source string
// Aliases maps source ID → account name → roster email.
Aliases map[string]map[string]string
// NonHuman maps source ID → account names that are not people.
NonHuman map[string][]string
}
RosterLink is the project's experimental.roster block as the evaluator needs it. Account names are lowercased; they match a record's id or its payload.username.
type RunPlan ¶
type RunPlan struct {
Framework string
Period Period
Policies []PlannedPolicy
}
RunPlan is the fully-resolved output of L3. The collector and evaluator iterate it; no further config lookups are needed at run time.
PlannedPolicy entries with ShouldEvaluate=false are present in the plan so the orchestrator can emit a carry-forward result for each, but the collector and evaluator skip them.
func Plan ¶
Plan computes the run plan. Errors surface back to the orchestrator as exit-code-3 conditions. No I/O happens here.
func PlanFull ¶ added in v0.89.0
PlanFull plans every policy of the framework, ignoring in.Filter and in.PolicyStates, and without requiring a manual.pdf source. It answers "which sources does this framework bind at all?", so a filtered run judges experimental.scope and warns about unbound sources against the whole framework, not the subset it happens to evaluate. Never use it to collect or evaluate.
type SourceCaveatWarning ¶ added in v0.81.0
type SourceCaveatWarning struct {
Slot string
SourceID string
EvidenceType string
Field string
Detail string
// Alternatives names the other sources bound to the same slot that
// declared no caveat on this field, sorted. When non-empty the remedy is
// a bindings: pin, because those sources hold the real answer for the
// same humans and the evaluator is unioning them with this one. When
// empty there is nothing to pin to and the finding is genuine.
Alternatives []string
}
SourceCaveatWarning reports that a source bound to a slot declared it cannot really observe a field this policy's pass_when reads.
It is advisory. The policy still plans, collects and evaluates exactly as it would have — a caveat changes no status, no count and nothing on the wire. What it changes is that the operator finds out, at plan time, that a control is being graded partly on a value nobody measured.