diff --git a/docs/internal/briefing.md b/docs/internal/briefing.md index 0c31de6..d19c613 100644 --- a/docs/internal/briefing.md +++ b/docs/internal/briefing.md @@ -78,9 +78,12 @@ modules. SPC discussion is omitted unless a retained categorical outlook meets the package's severity criterion and matching discussion text exists. Effective units, timezone, and location context arrive in `ModuleContext` from -configuration and resolved report metadata. Field defaults are owned by -[configuration](../config.md), and prompt-package layout is owned by -[prompt input](prompt-input.md). +configuration and resolved report metadata. Report preparation also creates one +`PreparedIdentity` for the shared report identity, timing, configuration +context, and source warnings. Briefing metadata derives its matching fields +from that value; module projections retain their prompt-safe shape. Field +defaults are owned by [configuration](../config.md), and prompt-package layout +is owned by [prompt input](prompt-input.md). ## Verification and invariants diff --git a/docs/internal/prepared-report.md b/docs/internal/prepared-report.md index 12dee26..abf3ea9 100644 --- a/docs/internal/prepared-report.md +++ b/docs/internal/prepared-report.md @@ -4,11 +4,17 @@ execution. This is the immutable boundary shared by ordinary report generation and profile comparison; it is not a durable artifact. -Preparation builds report facts, the configured module snapshot, briefing -metadata, the curated prompt-input package, serialized YAML, and the -generated-text definition. It deep-copies mutable facts, snapshots, metadata, -and data-package bytes before returning them. Consumers receive independent -copies so one execution cannot change another's input or rendering context. +Preparation first establishes one `PreparedIdentity` for the report run, report +and prompt IDs, variant, generation time, units, timezone, valid period, +location, and source warnings. It then builds report facts, the configured +module snapshot, briefing metadata, the curated prompt-input package, +serialized YAML, and the generated-text definition. The current metadata +projections remain equivalent to that identity while each retains its own +boundary-specific shape. + +Preparation deep-copies mutable facts, snapshots, identity, metadata, and +data-package bytes before returning them. Consumers receive independent copies +so one execution cannot change another's input or rendering context. Single-report generation executes one prepared profile and publishes its Markdown. Comparison prepares once, gives every selected profile the same YAML diff --git a/internal/app/prepared_report.go b/internal/app/prepared_report.go index 613d8fb..47e4580 100644 --- a/internal/app/prepared_report.go +++ b/internal/app/prepared_report.go @@ -20,6 +20,7 @@ type preparedReport struct { resolved report.Resolved reportFacts ReportFacts moduleSnapshot module.Snapshot + identity briefing.PreparedIdentity briefingMetadata briefing.Metadata sourceWarnings []weatherdata.SourceWarning dataPackage []byte @@ -54,11 +55,13 @@ func prepareReport(req prepareReportRequest) (preparedReport, error) { if err != nil { return preparedReport{}, &preparationError{operation: "build report facts", err: err} } + buildContext := briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected) + identity := briefing.BuildPreparedIdentity(buildContext) moduleSnapshot, err := BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{Config: req.Config, Resolved: req.Resolved}, reportFacts) if err != nil { return preparedReport{}, &preparationError{operation: "build module snapshot", err: err} } - metadata := briefing.BuildMetadata(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected)) + metadata := briefing.BuildMetadata(identity, buildContext.Bundle) dataPackage, err := promptinput.Build(promptinput.BuildRequest{Metadata: promptMetadata(metadata), Modules: moduleSnapshot}) if err != nil { return preparedReport{}, &preparationError{operation: "build data package", err: err} @@ -80,6 +83,10 @@ func prepareReport(req prepareReportRequest) (preparedReport, error) { if err != nil { return preparedReport{}, &preparationError{operation: "copy prepared module snapshot", err: err} } + clonedIdentity, err := clonePreparedValue(identity) + if err != nil { + return preparedReport{}, &preparationError{operation: "copy prepared identity", err: err} + } clonedMetadata, err := clonePreparedValue(metadata) if err != nil { return preparedReport{}, &preparationError{operation: "copy prepared briefing metadata", err: err} @@ -88,8 +95,9 @@ func prepareReport(req prepareReportRequest) (preparedReport, error) { resolved: cloneResolved(req.Resolved), reportFacts: clonedFacts, moduleSnapshot: clonedSnapshot, + identity: clonedIdentity, briefingMetadata: clonedMetadata, - sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedMetadata.SourceWarnings...), + sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedIdentity.SourceWarnings...), dataPackage: append([]byte(nil), serializedDataPackage...), handler: handler, } diff --git a/internal/app/prepared_report_test.go b/internal/app/prepared_report_test.go index c090f63..3d9043f 100644 --- a/internal/app/prepared_report_test.go +++ b/internal/app/prepared_report_test.go @@ -5,7 +5,9 @@ import ( "reflect" "testing" + "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/collect" + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) @@ -29,11 +31,12 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) { if err != nil { t.Fatalf("second prepareReport() error = %v", err) } - if len(prepared.dataPackage) == 0 || !bytes.Equal(prepared.dataPackage, repeated.dataPackage) || !reflect.DeepEqual(prepared.briefingMetadata, repeated.briefingMetadata) { - t.Fatalf("prepared package/metadata are not deterministic: %q/%#v", prepared.dataPackage, prepared.briefingMetadata) + if len(prepared.dataPackage) == 0 || !bytes.Equal(prepared.dataPackage, repeated.dataPackage) || !reflect.DeepEqual(prepared.identity, repeated.identity) || !reflect.DeepEqual(prepared.briefingMetadata, repeated.briefingMetadata) { + t.Fatalf("prepared package/identity/metadata are not deterministic: %q/%#v/%#v", prepared.dataPackage, prepared.identity, prepared.briefingMetadata) } originalDataPackage := append([]byte(nil), prepared.dataPackage...) + originalIdentity := prepared.identity originalMetadata := prepared.briefingMetadata originalWarnings := append([]weatherdata.SourceWarning(nil), prepared.sourceWarnings...) metadata, snapshot, reportFacts, err := prepared.renderInputs() @@ -52,7 +55,7 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) { bundle.Sources[0].Query["mutated"] = "true" } - if !bytes.Equal(prepared.dataPackage, originalDataPackage) || !reflect.DeepEqual(prepared.briefingMetadata, originalMetadata) || !reflect.DeepEqual(prepared.sourceWarnings, originalWarnings) { + if !bytes.Equal(prepared.dataPackage, originalDataPackage) || !reflect.DeepEqual(prepared.identity, originalIdentity) || !reflect.DeepEqual(prepared.briefingMetadata, originalMetadata) || !reflect.DeepEqual(prepared.sourceWarnings, originalWarnings) { t.Fatalf("prepared values changed after caller mutation: %#v", prepared) } if prepared.reportFacts.Collected.Hourly.Periods[0].TextDescription == "mutated after preparation" { @@ -62,3 +65,47 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) { t.Fatal("prepared report values retain consumer mutation") } } + +func TestPrepareReportSharesOneMetadataIdentity(t *testing.T) { + cfg := generationConfig() + bundle := generationBundle(t) + resolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, Report: ReportDaily, + Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), + }, generationTime("2026-05-29T08:30:00-05:00")) + if err != nil { + t.Fatalf("ResolveGenerate() error = %v", err) + } + prepared, err := prepareReport(prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}}) + if err != nil { + t.Fatalf("prepareReport() error = %v", err) + } + + identity := prepared.identity + metadata := prepared.briefingMetadata + if metadata.RunID != identity.RunID || metadata.ReportID != identity.ReportID || metadata.Variant != identity.Variant || metadata.PromptID != identity.PromptID || !metadata.GeneratedAt.Equal(identity.GeneratedAt) || metadata.Units != identity.Units || metadata.Timezone != identity.Timezone || metadata.ValidPeriod != identity.ValidPeriod || !reflect.DeepEqual(metadata.Location, identity.Location) || !reflect.DeepEqual(metadata.SourceWarnings, identity.SourceWarnings) { + t.Fatalf("briefing metadata does not match prepared identity: %#v/%#v", metadata, identity) + } + + prompt := promptMetadata(metadata) + if prompt.RunID != identity.RunID || prompt.ReportID != identity.ReportID || prompt.Variant != identity.Variant || prompt.PromptID != identity.PromptID || !prompt.GeneratedAt.Equal(identity.GeneratedAt) || prompt.Timezone != identity.Timezone || prompt.ValidPeriod != identity.ValidPeriod || !reflect.DeepEqual(prompt.SourceWarnings, identity.SourceWarnings) { + t.Fatalf("prompt metadata does not match prepared identity: %#v/%#v", prompt, identity) + } + + moduleMetadata, found, err := module.StanzaValue[briefing.MetadataModule](prepared.moduleSnapshot, "metadata") + if err != nil || !found { + t.Fatalf("metadata stanza = %#v/%t/%v", moduleMetadata, found, err) + } + if moduleMetadata.RunID != identity.RunID || moduleMetadata.ReportID != identity.ReportID || moduleMetadata.Variant != identity.Variant || moduleMetadata.PromptID != identity.PromptID || !moduleMetadata.GeneratedAt.Equal(identity.GeneratedAt) || moduleMetadata.Units != identity.Units || moduleMetadata.Timezone != identity.Timezone || moduleMetadata.ValidPeriod != identity.ValidPeriod || !reflect.DeepEqual(moduleMetadata.Location, identity.Location) { + t.Fatalf("module metadata does not match prepared identity: %#v/%#v", moduleMetadata, identity) + } + if len(moduleMetadata.SourceWarnings) != len(identity.SourceWarnings) { + t.Fatalf("module source warnings = %#v, want %#v", moduleMetadata.SourceWarnings, identity.SourceWarnings) + } + for index, warning := range identity.SourceWarnings { + summary := moduleMetadata.SourceWarnings[index] + if summary.Source != warning.Source || summary.Code != warning.Code || summary.Severity != warning.Severity || summary.Message != warning.Message || summary.CompletenessImpact != warning.CompletenessImpact { + t.Fatalf("module source warning %d = %#v, want %#v", index, summary, warning) + } + } +} diff --git a/internal/briefing/package.go b/internal/briefing/package.go index 3ce5bfb..7f8b28d 100644 --- a/internal/briefing/package.go +++ b/internal/briefing/package.go @@ -26,6 +26,21 @@ type Metadata struct { Alerts *AlertStatus `json:"alerts,omitempty"` } +// PreparedIdentity is the single prepared authority for report identity, +// timing, configuration context, and source warnings. +type PreparedIdentity struct { + RunID string `json:"runId"` + ReportID report.ID `json:"reportId"` + Variant string `json:"variant,omitempty"` + PromptID string `json:"promptId"` + GeneratedAt time.Time `json:"generatedAt"` + Units string `json:"units"` + Timezone string `json:"timezone"` + ValidPeriod timeutil.Period `json:"validPeriod"` + Location *LocationContext `json:"location,omitempty"` + SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"` +} + type LocationContext struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` @@ -59,24 +74,39 @@ type BuildContext struct { Location *LocationContext } -func BuildMetadata(ctx BuildContext) Metadata { +func BuildPreparedIdentity(ctx BuildContext) PreparedIdentity { metadata := ctx.Resolved.Metadata() - sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle) + return PreparedIdentity{ + RunID: metadata.RunID, + ReportID: metadata.ReportID, + Variant: variantForReport(metadata.ReportID), + PromptID: metadata.PromptID, + GeneratedAt: metadata.GeneratedAt, + Units: ctx.Units, + Timezone: ctx.Timezone, + ValidPeriod: metadata.ValidPeriod, + Location: copyLocation(ctx.Location), + SourceWarnings: append([]weatherdata.SourceWarning(nil), sourceWarnings(ctx.Bundle)...), + } +} + +func BuildMetadata(identity PreparedIdentity, bundle *weatherdata.Bundle) Metadata { + sourceLocationID, sourceLocation := sourceLocation(bundle) return Metadata{ - RunID: metadata.RunID, - ReportID: metadata.ReportID, - Variant: variantForReport(metadata.ReportID), - PromptID: metadata.PromptID, - GeneratedAt: metadata.GeneratedAt, - Units: ctx.Units, - Timezone: ctx.Timezone, - ValidPeriod: metadata.ValidPeriod, - Location: copyLocation(ctx.Location), + RunID: identity.RunID, + ReportID: identity.ReportID, + Variant: identity.Variant, + PromptID: identity.PromptID, + GeneratedAt: identity.GeneratedAt, + Units: identity.Units, + Timezone: identity.Timezone, + ValidPeriod: identity.ValidPeriod, + Location: copyLocation(identity.Location), SourceLocationID: sourceLocationID, SourceLocation: sourceLocation, - Sources: sourceMetadata(ctx.Bundle), - SourceWarnings: sourceWarnings(ctx.Bundle), - Alerts: alertStatus(ctx.Bundle), + Sources: sourceMetadata(bundle), + SourceWarnings: append([]weatherdata.SourceWarning(nil), identity.SourceWarnings...), + Alerts: alertStatus(bundle), } } diff --git a/internal/briefing/prepared_identity_test.go b/internal/briefing/prepared_identity_test.go new file mode 100644 index 0000000..fb44c2a --- /dev/null +++ b/internal/briefing/prepared_identity_test.go @@ -0,0 +1,32 @@ +package briefing + +import ( + "reflect" + "testing" +) + +func TestPreparedIdentityBuildsEquivalentMetadata(t *testing.T) { + moduleContext := testModuleContext() + context := BuildContext{ + Resolved: moduleContext.Resolved, + Bundle: moduleContext.Collected.Bundle(), + Units: moduleContext.Units, + Timezone: moduleContext.Timezone, + Location: moduleContext.Location, + } + identity := BuildPreparedIdentity(context) + metadata := BuildMetadata(identity, context.Bundle) + + if metadata.RunID != identity.RunID || metadata.ReportID != identity.ReportID || metadata.Variant != identity.Variant || metadata.PromptID != identity.PromptID || !metadata.GeneratedAt.Equal(identity.GeneratedAt) || metadata.Units != identity.Units || metadata.Timezone != identity.Timezone || metadata.ValidPeriod != identity.ValidPeriod { + t.Fatalf("metadata identity = %#v, want %#v", metadata, identity) + } + if !reflect.DeepEqual(metadata.Location, identity.Location) || !reflect.DeepEqual(metadata.SourceWarnings, identity.SourceWarnings) { + t.Fatalf("metadata location/warnings = %#v/%#v, want %#v/%#v", metadata.Location, metadata.SourceWarnings, identity.Location, identity.SourceWarnings) + } + + metadata.Location.Name = "consumer mutation" + metadata.SourceWarnings = append(metadata.SourceWarnings, moduleContext.Collected.SourceWarnings[0]) + if identity.Location.Name == "consumer mutation" || len(identity.SourceWarnings) != len(moduleContext.Collected.SourceWarnings) { + t.Fatalf("identity changed through metadata mutation: %#v", identity) + } +}