diff --git a/internal/fm/frontmatter.go b/internal/fm/frontmatter.go index c55c0a9..72ed55e 100644 --- a/internal/fm/frontmatter.go +++ b/internal/fm/frontmatter.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/misty-step/exocortex/internal/okf" "go.yaml.in/yaml/v3" ) @@ -107,40 +108,13 @@ func warnf(rule, format string, a ...any) Finding { var knownKeys = map[string]bool{ "type": true, "status": true, "created": true, "description": true, "tags": true, "provenance": true, -} - -// Validate applies a named profile to an already-parsed document. -// Errors are contract failures; warnings are advisory and never -// blocking under any profile. -func Validate(profile string, d Document) ([]Finding, error) { - switch profile { - case "daybook": - return validateDaybook(d) - case "strict": - return validateStrict(d) - default: - return nil, fmt.Errorf("unknown profile %q", profile) - } -} - -func requireFrontmatter(d Document) error { - if !d.Note.HasFM { - return contract(errf("fm_missing", "frontmatter missing")) - } - if d.err != nil { - return contract(errf("fm_unparseable", "frontmatter is not parseable YAML: %v", d.err)) - } - return nil -} - -func validateDaybook(d Document) ([]Finding, error) { - if err := requireFrontmatter(d); err != nil { - return nil, err - } - if t, ok := d.Map["type"].(string); !ok || strings.TrimSpace(t) == "" { - return nil, contract(errf("type_missing", "frontmatter has no non-empty \"type\"")) - } - return daybookWarnings(d), nil + // OKF v0.2's optional provenance, lifecycle, and computation + // families are part of the frontmatter vocabulary even when a + // particular concept does not use them. + "title": true, "resource": true, "sources": true, + "generated": true, "verified": true, "stale_after": true, + "runtime": true, "parameters": true, "computation": true, + "executor": true, "attester": true, "usage_window": true, } func validateStrict(d Document) ([]Finding, error) { @@ -158,6 +132,7 @@ func validateStrict(d Document) ([]Finding, error) { fs = append(fs, errf("created_format", "created %q is not RFC3339", c)) } } + fs = append(fs, validateOKF(d, true)...) for _, f := range fs { if f.Level == "error" { return fs, contract(f) @@ -179,6 +154,40 @@ func ContractFinding(err error) (Finding, bool) { return ce.f, ok } +// Validate applies a named profile to an already-parsed document. +// Errors are contract failures; warnings are advisory and never +// blocking under any profile. +func Validate(profile string, d Document) ([]Finding, error) { + switch profile { + case "daybook": + return validateDaybook(d) + case "strict": + return validateStrict(d) + default: + return nil, fmt.Errorf("unknown profile %q", profile) + } +} + +func requireFrontmatter(d Document) error { + if !d.Note.HasFM { + return contract(errf("fm_missing", "frontmatter missing")) + } + if d.err != nil { + return contract(errf("fm_unparseable", "frontmatter is not parseable YAML: %v", d.err)) + } + return nil +} + +func validateDaybook(d Document) ([]Finding, error) { + if err := requireFrontmatter(d); err != nil { + return nil, err + } + if t, ok := d.Map["type"].(string); !ok || strings.TrimSpace(t) == "" { + return nil, contract(errf("type_missing", "frontmatter has no non-empty \"type\"")) + } + return daybookWarnings(d), nil +} + func daybookWarnings(d Document) []Finding { // Journal micro-notes (type: memo) are quiet under the daybook // profile: they are not wiki notes, and constant key warnings would @@ -197,6 +206,7 @@ func daybookWarnings(d Document) []Finding { fs = append(fs, warnf("created_format", "created %q is not RFC3339", c)) } } + fs = append(fs, validateOKF(d, false)...) var unknown []string for k := range d.Map { if !knownKeys[k] { @@ -210,6 +220,21 @@ func daybookWarnings(d Document) []Finding { return fs } +// validateOKF applies the structural checks shared by the OKF v0.2 +// provenance and lifecycle fields. Daybook reports malformed optional +// fields as warnings; strict promotes the same findings to errors. +func validateOKF(d Document, strict bool) []Finding { + var fs []Finding + for _, v := range okf.Validate(&d.root) { + if strict { + fs = append(fs, errf(v.Rule, "%s", v.Message)) + } else { + fs = append(fs, warnf(v.Rule, "%s", v.Message)) + } + } + return fs +} + func empty(v any) bool { switch t := v.(type) { case nil: diff --git a/internal/fm/frontmatter_test.go b/internal/fm/frontmatter_test.go index 5908ee8..7a6fb4c 100644 --- a/internal/fm/frontmatter_test.go +++ b/internal/fm/frontmatter_test.go @@ -114,6 +114,84 @@ func TestValidateStrict(t *testing.T) { } } +// Malformed optional OKF fields violate the same rules under both +// profiles; only the level policy differs. +func TestValidateOKFLevelPolicy(t *testing.T) { + malformed := `--- +type: Metric +status: stable +created: 2026-06-25T09:00:00Z +description: Revenue. +tags: [finance] +generated: + by: "human:" + at: 2026-06-20 +--- +body +` + fs, err := validate("daybook", malformed) + if err != nil { + t.Fatalf("malformed optional signals must warn, not fail, under daybook: %v", err) + } + if !hasRule(fs, "generated_by_format") || !hasRule(fs, "generated_at_format") { + t.Fatalf("daybook missing OKF violations in %+v", fs) + } + for _, f := range fs { + if f.Level != "warning" { + t.Fatalf("daybook must warn, never error, on %v", f) + } + } + _, err = validate("strict", malformed) + if err == nil { + t.Fatal("strict must reject malformed OKF v0.2 signals") + } + // With the floor keys satisfied, the contract error must be the + // promoted OKF violation, not the floor key_missing. + f, ok := ContractFinding(err) + if !ok { + t.Fatalf("strict error is not a finding: %v", err) + } + if f.Level != "error" || f.Rule != "generated_by_format" { + t.Fatalf("strict must promote the generated_by_format violation, got %+v", f) + } +} + +// Every OKF v0.2 vocabulary key is a known key under the daybook +// profile, and valid OKF metadata must not fail strict. +func TestValidateOKFKeysAreKnown(t *testing.T) { + raw := `--- +type: Attested Computation +status: stable +created: 2026-06-20T22:53:05Z +description: Revenue. +tags: [finance] +title: Revenue +resource: https://example.test/revenue +sources: [{resource: https://example.test/policy}] +generated: {by: reference_agent/gemini-2.5-pro, at: 2026-06-20T22:53:05Z} +verified: [{by: human:ahormati, at: 2026-06-25T09:00:00Z}] +stale_after: 2026-12-31T00:00:00+01:00 +runtime: bigquery +parameters: [{name: year, type: integer, required: true}] +computation: references/revenue.sql +executor: {resource: references/run.md} +attester: {resource: references/check.py} +usage_window: {from: 2026-06-01, to: 2026-06-30} +--- +body +` + fs, err := validate("daybook", raw) + if err != nil { + t.Fatalf("OKF v0.2 note must pass daybook: %v", err) + } + if hasRule(fs, "unknown_keys") { + t.Fatalf("OKF v0.2 keys should be known: %+v", fs) + } + if _, err := validate("strict", raw); err != nil { + t.Fatalf("valid OKF v0.2 metadata must pass strict: %v", err) + } +} + const richNote = `--- type: decision status: active # keep this comment diff --git a/internal/okf/okf.go b/internal/okf/okf.go new file mode 100644 index 0000000..73dce3b --- /dev/null +++ b/internal/okf/okf.go @@ -0,0 +1,247 @@ +// Package okf validates the optional OKF v0.2 metadata families — +// trust/lifecycle events, stale_after, and provenance — against the +// structural rules of the Open Knowledge Format v0.2 spec (SPEC §5, §10). +// Violations are neutral data; level policy (error vs warning) is the +// calling profile's decision. +package okf + +import ( + "fmt" + "strings" + "time" + + "go.yaml.in/yaml/v3" +) + +// Violation is one structural rule breach in OKF v0.2 metadata. +type Violation struct { + Rule string + Message string +} + +// Validate checks the optional OKF v0.2 fields of a parsed frontmatter +// document node. Absent fields are meaningful (SPEC §5) and never +// flagged; malformed present fields each yield one violation. +func Validate(root *yaml.Node) []Violation { + m := topMapping(root) + if m == nil { + return nil + } + var vs []Violation + vs = append(vs, validateGenerated(m)...) + vs = append(vs, validateVerified(m)...) + vs = append(vs, validateStaleAfter(m)...) + vs = append(vs, validateProvenance(m)...) + return vs +} + +// validTimestamp reports whether value parses as RFC3339. The RFC3339 +// layout enforces the explicit UTC offset OKF §5 requires for every +// timestamp key; the offset itself may be any valid offset, not just +// zero. +func validTimestamp(value string) bool { + _, err := time.Parse(time.RFC3339, value) + return err == nil +} + +func violationf(rule, format string, args ...any) Violation { + return Violation{Rule: rule, Message: fmt.Sprintf(format, args...)} +} + +func validateGenerated(root *yaml.Node) []Violation { + generated, ok := mappingValue(root, "generated") + if !ok { + return nil + } + return validateEvent("generated", generated, false) +} + +func validateVerified(root *yaml.Node) []Violation { + verified, ok := mappingValue(root, "verified") + if !ok { + return nil + } + verified = unwrapNode(verified) + if verified == nil { + return []Violation{{Rule: "verified_format", Message: "verified must be a mapping or list of mappings"}} + } + switch verified.Kind { + case yaml.MappingNode: + return validateEventFields("verified", "verified", verified, true) + case yaml.SequenceNode: + var vs []Violation + for i, event := range verified.Content { + vs = append(vs, validateEventFields( + fmt.Sprintf("verified[%d]", i), "verified", unwrapNode(event), true, + )...) + } + return vs + default: + return []Violation{{Rule: "verified_format", Message: "verified must be a mapping or list of mappings"}} + } +} + +func validateStaleAfter(root *yaml.Node) []Violation { + staleAfter, ok := mappingValue(root, "stale_after") + if !ok { + return nil + } + value, scalar := nodeScalar(staleAfter) + if scalar && validTimestamp(value) { + return nil + } + return []Violation{violationf("stale_after_format", + "stale_after %q must be an RFC3339 datetime with an explicit UTC offset", value)} +} + +func validateProvenance(root *yaml.Node) []Violation { + provenance, ok := mappingValue(root, "provenance") + if !ok { + return nil + } + provenance = unwrapNode(provenance) + if provenance == nil || provenance.Kind != yaml.MappingNode { + return []Violation{{Rule: "provenance_format", Message: "provenance must be a mapping"}} + } + at, present := mappingValue(provenance, "at") + if !present { + return nil + } + value, scalar := nodeScalar(at) + if scalar && validTimestamp(value) { + return nil + } + return []Violation{violationf("provenance_at_format", + "provenance.at %q must be an RFC3339 datetime with an explicit UTC offset", value)} +} + +func validateEvent(field string, event *yaml.Node, requireAt bool) []Violation { + event = unwrapNode(event) + if event == nil || event.Kind != yaml.MappingNode { + return []Violation{violationf(field+"_format", "%s must be a mapping", field)} + } + // generated.at is optional when the production time is unknown. + return validateEventFields(field, field, event, requireAt) +} + +func validateEventFields(field, ruleBase string, event *yaml.Node, requireAt bool) []Violation { + var vs []Violation + if event == nil || event.Kind != yaml.MappingNode { + return []Violation{violationf(ruleBase+"_format", "%s must be a mapping", field)} + } + by, ok := mappingValue(event, "by") + if !ok { + vs = append(vs, violationf(ruleBase+"_by_missing", "%s.by is required", field)) + } else if value, scalar := nodeScalar(by); !scalar || !validActor(value) { + value, _ := nodeScalar(by) + vs = append(vs, violationf(ruleBase+"_by_format", + "%s.by %q must be /, human:, or process:", field, value)) + } + at, ok := mappingValue(event, "at") + if !ok { + if requireAt { + vs = append(vs, violationf(ruleBase+"_at_missing", "%s.at is required", field)) + } + } else if value, scalar := nodeScalar(at); !scalar || !validTimestamp(value) { + value, _ := nodeScalar(at) + vs = append(vs, violationf(ruleBase+"_at_format", + "%s.at %q must be an RFC3339 datetime with an explicit UTC offset", field, value)) + } + return vs +} + +func validActor(value string) bool { + if value == "" || strings.TrimSpace(value) != value || len(strings.Fields(value)) != 1 { + return false + } + for _, prefix := range []string{"human:", "process:"} { + if strings.HasPrefix(value, prefix) { + return len(value) > len(prefix) + } + } + parts := strings.Split(value, "/") + return len(parts) == 2 && parts[0] != "" && parts[1] != "" +} + +func topMapping(root *yaml.Node) *yaml.Node { + if root == nil || len(root.Content) == 0 { + return nil + } + m := unwrapNode(root.Content[0]) + if m == nil || m.Kind != yaml.MappingNode { + return nil + } + return m +} + +func mappingValue(mapping *yaml.Node, key string) (*yaml.Node, bool) { + return mappingValueWithMerges(unwrapNode(mapping), key) +} + +// mappingValueWithMerges resolves key with yaml.v3 merge-key semantics: +// plain "<<", the long-form "!!merge" tag, and the explicit non-specific +// tag all merge; quoted and aliased keys are ordinary keys. +func mappingValueWithMerges(mapping *yaml.Node, key string) (*yaml.Node, bool) { + if mapping == nil || mapping.Kind != yaml.MappingNode { + return nil, false + } + var merges []*yaml.Node + for i := 0; i+1 < len(mapping.Content); i += 2 { + rawName := mapping.Content[i] + name := unwrapNode(rawName) + if name == nil || name.Kind != yaml.ScalarNode { + continue + } + if name.Value == key { + return mapping.Content[i+1], true + } + if isMergeKey(rawName) { + merges = append(merges, mapping.Content[i+1]) + } + } + for _, merged := range merges { + if value, ok := mergedMappingValue(merged, key); ok { + return value, true + } + } + return nil, false +} + +func mergedMappingValue(merged *yaml.Node, key string) (*yaml.Node, bool) { + merged = unwrapNode(merged) + if merged == nil { + return nil, false + } + if merged.Kind == yaml.MappingNode { + return mappingValueWithMerges(merged, key) + } + if merged.Kind != yaml.SequenceNode { + return nil, false + } + for _, item := range merged.Content { + if value, ok := mergedMappingValue(item, key); ok { + return value, true + } + } + return nil, false +} + +func isMergeKey(n *yaml.Node) bool { + return n != nil && n.Kind == yaml.ScalarNode && n.Value == "<<" && + (n.Tag == "" || n.Tag == "!" || n.ShortTag() == "!!merge") +} + +func unwrapNode(n *yaml.Node) *yaml.Node { + for n != nil && n.Kind == yaml.AliasNode { + n = n.Alias + } + return n +} + +func nodeScalar(n *yaml.Node) (string, bool) { + n = unwrapNode(n) + if n == nil || n.Kind != yaml.ScalarNode || n.Tag == "!!null" { + return "", false + } + return n.Value, true +} diff --git a/internal/okf/okf_test.go b/internal/okf/okf_test.go new file mode 100644 index 0000000..9d0bfcb --- /dev/null +++ b/internal/okf/okf_test.go @@ -0,0 +1,220 @@ +package okf + +import ( + "testing" + + "go.yaml.in/yaml/v3" +) + +func parse(t *testing.T, raw string) *yaml.Node { + t.Helper() + var root yaml.Node + if err := yaml.Unmarshal([]byte(raw), &root); err != nil { + t.Fatalf("fixture must parse: %v", err) + } + return &root +} + +func validate(t *testing.T, raw string) []Violation { + t.Helper() + return Validate(parse(t, raw)) +} + +func hasViolation(vs []Violation, rule string) bool { + for _, v := range vs { + if v.Rule == rule { + return true + } + } + return false +} + +const richFixture = `--- +type: Attested Computation +title: Revenue +description: Recognized revenue. +resource: https://example.test/revenue +sources: + - resource: https://example.test/policy +generated: + by: reference_agent/gemini-2.5-pro + at: 2026-06-20T22:53:05Z +verified: + - by: human:ahormati + at: 2026-06-25T09:00:00Z + - by: process:finance-nightly + at: 2026-06-26T02:00:00+00:00 +stale_after: 2026-12-31T00:00:00+01:00 +runtime: bigquery +parameters: + - name: year + type: integer + required: true +computation: references/revenue.sql +executor: + resource: references/run.md +attester: + resource: references/check.py +usage_window: + from: 2026-06-01 + to: 2026-06-30 +provenance: + agent: kernel + at: 2026-06-28T14:00:00Z + via: cli +status: stable +created: 2026-06-20T22:53:05Z +tags: [finance] +--- +body +` + +func TestValidateRichFixture(t *testing.T) { + if vs := validate(t, richFixture); len(vs) != 0 { + t.Fatalf("valid OKF v0.2 signals must not be flagged: %+v", vs) + } +} + +// OKF v0.2 SPEC §5: "Every timestamp-valued key in OKF is an ISO 8601 +// datetime with an explicit UTC offset." Any explicit offset conforms; +// offset zero is one valid spelling, not the only one. +func TestValidateAcceptsNonZeroOffsets(t *testing.T) { + raw := `--- +generated: + by: human:alice + at: 2026-06-20T22:53:05+05:30 +verified: + - by: process:nightly + at: 2026-06-25T09:00:00-04:00 +stale_after: 2026-12-31T00:00:00+01:00 +provenance: + at: 2026-06-28T14:00:00+02:00 +--- +` + vs := validate(t, raw) + for _, rule := range []string{ + "generated_at_format", "verified_at_format", "stale_after_format", "provenance_at_format", + } { + if hasViolation(vs, rule) { + t.Fatalf("explicit nonzero offset must satisfy %s: %+v", rule, vs) + } + } +} + +// The offset must be explicit: RFC3339 datetimes without an offset +// fail the layout parse and violate every timestamp rule. +func TestValidateRejectsMissingOffset(t *testing.T) { + raw := `--- +generated: + by: human:alice + at: 2026-06-20T22:53:05 +provenance: + at: 2026-06-28T14:00:00 +--- +` + vs := validate(t, raw) + if !hasViolation(vs, "generated_at_format") || !hasViolation(vs, "provenance_at_format") { + t.Fatalf("offset-less datetimes must violate: %+v", vs) + } +} + +func TestValidateRejectsMalformedSignals(t *testing.T) { + raw := `--- +generated: + by: "human:" + at: 2026-06-20 +verified: + - by: agent + at: 2026-06-25 +stale_after: not-a-date +provenance: + at: yesterday +--- +` + vs := validate(t, raw) + for _, rule := range []string{ + "generated_by_format", "generated_at_format", + "verified_by_format", "verified_at_format", + "stale_after_format", "provenance_at_format", + } { + if !hasViolation(vs, rule) { + t.Fatalf("missing %s in %+v", rule, vs) + } + } +} + +func TestValidateVerifiedMapping(t *testing.T) { + vs := validate(t, "---\nverified: {by: process:nightly, at: 2026-06-25T09:00:00Z}\n---\n") + if hasViolation(vs, "verified_format") { + t.Fatalf("bare verified mapping is valid OKF v0.2: %+v", vs) + } +} + +func TestValidateGeneratedAtOptional(t *testing.T) { + raw := `--- +generated: + by: human:alice +--- +` + if vs := validate(t, raw); len(vs) != 0 { + t.Fatalf("generated.at is optional when production time is unknown: %+v", vs) + } +} + +func TestValidateMergeKeys(t *testing.T) { + plain := `--- +defaults: &defaults + generated: + by: "bad actor" + at: 2026-06-20T22:53:05Z +<<: *defaults +--- +` + if vs := validate(t, plain); !hasViolation(vs, "generated_by_format") { + t.Fatalf("merged generated metadata was skipped: %+v", vs) + } + quoted := `--- +:"<<": + generated: + by: "bad actor" + at: 2026-06-20T22:53:05Z +--- +` + if vs := validate(t, quoted); hasViolation(vs, "generated_by_format") { + t.Fatalf("quoted << key must not act as a merge: %+v", vs) + } + longTag := `--- +defaults: &defaults + generated: + by: "bad actor" + at: 2026-06-20T22:53:05Z +! "<<": *defaults +--- +` + if vs := validate(t, longTag); !hasViolation(vs, "generated_by_format") { + t.Fatalf("long-form merge tag must act as a merge: %+v", vs) + } + aliasKey := `--- +defaults: &defaults + generated: + by: "bad actor" + at: 2026-06-20T22:53:05Z +merge_name: &merge_name "<<" +*merge_name: *defaults +--- +` + if vs := validate(t, aliasKey); hasViolation(vs, "generated_by_format") { + t.Fatalf("aliased << key must not act as a merge: %+v", vs) + } + explicitTag := `--- +defaults: &defaults + generated: + by: "bad actor" + at: 2026-06-20T22:53:05Z +! <<: *defaults +--- +` + if vs := validate(t, explicitTag); !hasViolation(vs, "generated_by_format") { + t.Fatalf("explicit non-specific merge tag must act as a merge: %+v", vs) + } +} diff --git a/modules.budget b/modules.budget index 2778f33..774f70a 100644 --- a/modules.budget +++ b/modules.budget @@ -12,7 +12,14 @@ kernel internal/kernel 58400 # FlagSet-driven splitArgs: VisitAll replaces per-command value-flag maps. cli internal/cli 16200 -fm internal/fm 8100 +# PR #40: OKF v0.2 vocabulary split out of fm into internal/okf +# (11,593 -> 9,057 here, 3,692 there); timestamp messages aligned to +# OKF SPEC §5 (RFC3339 with an explicit UTC offset — any offset, not +# just zero). The 8100 -> 9200 rise covers the residual fm-owned +# profile wiring after the split: level-policy (incl. strict OKF +# promotion, mutation-checked) and known-keys coverage; measured 9,057. +fm internal/fm 9200 +okf internal/okf 4000 mcp internal/mcp 5000 orient internal/orient 4500 qmd internal/qmd 9000