Files
dingtalk-workspace-cli/internal/cli/schema_contract_model.go
T

1127 lines
37 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cli
import (
"bytes"
"encoding/json"
"fmt"
"reflect"
"sort"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// SchemaRegistry is the resolved, typed Agent contract. Source adapters and
// precedence resolvers populate this model; wire payloads are rendered only at
// the outer boundary by ToPayload. This keeps map[string]any out of the
// contract assembly path.
type SchemaRegistry struct {
Kind string
Level string
Source string
Products []ProductSpec
AgentMetadata json.RawMessage
}
// ProductSpec is one deterministic product grouping in SchemaRegistry.
type ProductSpec struct {
ID string
Name string
Description string
Runtime bool
Tools []ToolSpec
Selection contract.SelectionSpec
FieldProvenance map[string]contract.FieldProvenance
}
// ToolSpec is the single assembled representation of a command contract.
// Safety, interface and selection are typed sub-models even though ToPayload
// preserves the historical flat JSON shape for compatibility.
type ToolSpec struct {
Identity contract.ToolIdentitySpec
Display string
Title string
Description string
MetadataSource string
Parameters []ParameterSpec
Constraints RuntimeSchemaConstraints
Positionals []contract.RuntimeSchemaPositional
DryRun *contract.DryRunSpec
Result *contract.ResultSpec
Safety contract.SafetySpec
Interface contract.InterfaceSpec
Selection contract.SelectionSpec
FieldProvenance map[string]contract.FieldProvenance
}
// ParameterSpec is one resolved CLI flag projection. Name is the CLI flag key
// used in the legacy parameters object. Default and Example are RawMessage
// because JSON literals are polymorphic; they are still typed JSON values and
// never participate in precedence as map[string]any.
type ParameterSpec struct {
Name string
Type string
Description string
Property string
Required bool
CLIRequired bool
RequiredWhen string
Default json.RawMessage
InterfaceDefault json.RawMessage
Example json.RawMessage
Format string
Enum []string
InterfaceDescription string
InterfaceType string
FieldProvenance map[string]contract.FieldProvenance
}
// Final provenance coverage is an explicit contract, not a truthiness check.
// These fields all pass through a precedence resolver and therefore must keep
// a winner even when the delivered value is false, "", [], or null.
var requiredToolProvenanceFields = [...]string{
"canonical_path",
"effect",
"risk",
"confirmation",
"idempotency",
"interface_ref",
"interface_mode",
"availability",
"agent_summary",
}
var conditionalSelectionProvenanceFields = [...]string{
"use_when",
"avoid_when",
"prerequisites",
"tips",
"workflow_refs",
"examples",
}
var requiredParameterProvenanceFields = [...]string{
"property",
"type",
"description",
"required",
"required_when",
}
// RuntimeToolSpecInput is the typed hand-off from runtime source resolution to
// contract assembly. Adapters resolve candidates first, then call
// ToolSpecFromRuntime exactly once; payload rendering does no further merging.
type RuntimeToolSpecInput struct {
Identity contract.ToolIdentitySpec
Display string
Title string
Description string
MetadataSource string
Parameters []ParameterSpec
Constraints RuntimeSchemaConstraints
Positionals []contract.RuntimeSchemaPositional
DryRun *contract.DryRunSpec
Result *contract.ResultSpec
Safety contract.SafetySpec
Interface contract.InterfaceSpec
Selection contract.SelectionSpec
FieldProvenance map[string]contract.FieldProvenance
}
// SchemaIndex is the immutable navigation view over one normalized registry.
// It mirrors Lark's typed catalog split: SchemaRegistry owns data, SchemaIndex
// owns deterministic identity/path resolution, and serializers only render a
// resolved ToolSpec.
type SchemaIndex struct {
registry SchemaRegistry
byProduct map[string]int
byCanonical map[string]schemaToolLocation
byToolPath map[string]string
byCLIPath map[string]string
}
type schemaToolLocation struct {
product int
tool int
}
// SchemaSnapshotPayload is the final wire boundary for the release snapshot.
// Catalog summaries and full Tools are both derived from the same typed
// ToolSpec instances, preventing the two snapshot layers from drifting.
type SchemaSnapshotPayload struct {
Catalog map[string]any
Tools map[string]map[string]any
}
var (
snapshotToolSummary = ToolSpec.ToSummaryPayload
snapshotToolPayload = ToolSpec.ToPayload
snapshotProductSummary = ProductSpec.ToSummaryPayload
)
// SchemaRegistryFromRuntime constructs and validates the one registry shared
// by leaf lookup, Catalog summaries and full export.
func SchemaRegistryFromRuntime(source string, products []ProductSpec) (SchemaRegistry, error) {
registry := SchemaRegistry{
Kind: "schema",
Level: "catalog",
Source: strings.TrimSpace(source),
Products: products,
}.Sorted()
if _, err := registry.Index(); err != nil {
return SchemaRegistry{}, err
}
return registry, nil
}
// ToolSpecFromRuntime validates and normalizes the fully resolved runtime
// input. It does not choose between sources; that is the resolver's job.
// Snapshot loading shares this path and validates a delivered winner exactly
// as serialized: it deliberately does not repair provenance, so a snapshot
// whose winner differs from the final field must fail closed.
func ToolSpecFromRuntime(input RuntimeToolSpecInput) (ToolSpec, error) {
spec := ToolSpec(input).normalized()
if err := spec.Validate(); err != nil {
return ToolSpec{}, err
}
return spec, nil
}
func (t ToolSpec) provenanceValue(field string) (any, bool) {
switch field {
case "canonical_path":
return t.Identity.CanonicalPath, true
case "title":
return t.Title, true
case "description":
return t.Description, true
case "metadata_source":
return t.MetadataSource, true
case "dry_run":
return t.DryRun, true
case "effect":
return t.Safety.Effect, true
case "effect_source":
return t.Safety.EffectSource, true
case "risk":
return t.Safety.Risk, true
case "confirmation":
return t.Safety.Confirmation, true
case "idempotency":
return t.Safety.Idempotency, true
case "interface_ref":
return t.Interface.Ref, true
case "interface_mode":
return t.Interface.Mode, true
case "availability":
return t.Interface.Availability, true
case "interface_reason":
return t.Interface.Reason, true
case "agent_summary":
return t.Selection.AgentSummary, true
case "use_when":
return t.Selection.UseWhen, true
case "avoid_when":
return t.Selection.AvoidWhen, true
case "prerequisites":
return t.Selection.Prerequisites, true
case "tips":
return t.Selection.Tips, true
case "workflow_refs":
return t.Selection.WorkflowRefs, true
case "examples":
return t.Selection.Examples, true
case "reviewed":
return t.Selection.Reviewed, true
default:
return nil, false
}
}
func (p ParameterSpec) provenanceValue(field string) (any, bool) {
switch field {
case "name":
return p.Name, true
case "type":
return p.Type, true
case "description":
return p.Description, true
case "property":
return p.Property, true
case "required":
return p.Required, true
case "cli_required":
return p.CLIRequired, true
case "required_when":
return p.RequiredWhen, true
case "default":
return p.Default, true
case "interface_default":
return p.InterfaceDefault, true
case "example":
return p.Example, true
case "format":
return p.Format, true
case "enum":
return p.Enum, true
case "interface_description":
return p.InterfaceDescription, true
case "interface_type":
return p.InterfaceType, true
default:
return nil, false
}
}
// Index validates all registry identities and returns deterministic navigation
// indexes. Canonical paths, primary CLI paths and reviewed aliases must each
// resolve unambiguously.
func (r SchemaRegistry) Index() (SchemaIndex, error) {
r = r.Sorted()
index := SchemaIndex{
registry: r,
byProduct: make(map[string]int, len(r.Products)),
byCanonical: make(map[string]schemaToolLocation),
byToolPath: make(map[string]string),
byCLIPath: make(map[string]string),
}
for productIndex, product := range r.Products {
if product.ID == "" {
return SchemaIndex{}, fmt.Errorf("schema product id is empty")
}
if _, exists := index.byProduct[product.ID]; exists {
return SchemaIndex{}, fmt.Errorf("duplicate schema product %q", product.ID)
}
index.byProduct[product.ID] = productIndex
for field, provenance := range product.FieldProvenance {
if value, ok := product.provenanceValue(field); ok {
if err := validateFinalFieldProvenance("product "+product.ID, field, provenance, value); err != nil {
return SchemaIndex{}, err
}
}
}
for toolIndex, tool := range product.Tools {
if err := tool.Validate(); err != nil {
return SchemaIndex{}, err
}
if err := validateCanonicalToolIdentity(tool); err != nil {
return SchemaIndex{}, err
}
if tool.Identity.ProductID != product.ID {
return SchemaIndex{}, fmt.Errorf("tool %s belongs to product %q, not containing product %q", tool.Identity.CanonicalPath, tool.Identity.ProductID, product.ID)
}
canonical := tool.Identity.CanonicalPath
if _, exists := index.byCanonical[canonical]; exists {
return SchemaIndex{}, fmt.Errorf("duplicate schema tool %q", canonical)
}
if existing, exists := index.byToolPath[canonical]; exists && existing != canonical {
return SchemaIndex{}, fmt.Errorf("canonical path %q conflicts with contract path for %s", canonical, existing)
}
index.byCanonical[canonical] = schemaToolLocation{product: productIndex, tool: toolIndex}
toolPaths := []string{tool.Identity.Path}
if tool.Identity.SourceProductID != "" && tool.Identity.SourceProductID != tool.Identity.ProductID {
toolPaths = append(toolPaths, tool.Identity.SourceProductID+"."+tool.Identity.Name)
}
for _, path := range sortedUniqueStrings(toolPaths) {
if path == canonical {
continue
}
if existing, exists := index.byToolPath[path]; exists && existing != canonical {
return SchemaIndex{}, fmt.Errorf("schema contract path %q resolves to both %s and %s", path, existing, canonical)
}
if _, exists := index.byCanonical[path]; exists {
return SchemaIndex{}, fmt.Errorf("schema contract path %q conflicts with a canonical path", path)
}
index.byToolPath[path] = canonical
}
paths := append([]string{tool.Identity.CLIPath, tool.Identity.PrimaryCLIPath}, tool.Identity.Aliases...)
for _, path := range sortedUniqueStrings(paths) {
path = normalizeSchemaCLIPath(path)
if existing, exists := index.byCLIPath[path]; exists && existing != canonical {
return SchemaIndex{}, fmt.Errorf("schema CLI path %q resolves to both %s and %s", path, existing, canonical)
}
index.byCLIPath[path] = canonical
}
}
}
return index, nil
}
// validateCanonicalToolIdentity applies only to ToolSpecs stored in the
// registry. Alias lookup creates a detached query view later, where cli_path
// and is_alias intentionally change; that projection is never allowed back
// into SchemaRegistry, --all, or the delivered Catalog.
func validateCanonicalToolIdentity(tool ToolSpec) error {
id := tool.Identity
canonical := strings.TrimSpace(id.CanonicalPath)
cliPath := normalizeSchemaCLIPath(id.CLIPath)
primaryCLIPath := normalizeSchemaCLIPath(id.PrimaryCLIPath)
if cliPath != primaryCLIPath {
return fmt.Errorf("canonical Schema tool %s cli_path %q must equal primary_cli_path %q", canonical, cliPath, primaryCLIPath)
}
if id.IsAlias {
return fmt.Errorf("canonical Schema tool %s must have is_alias=false", canonical)
}
return nil
}
// Registry returns the detached normalized registry owned by this index.
func (i SchemaIndex) Registry() SchemaRegistry { return i.registry }
// Product resolves one product by exact stable ID.
func (i SchemaIndex) Product(id string) (ProductSpec, bool) {
position, ok := i.byProduct[strings.TrimSpace(id)]
if !ok {
return ProductSpec{}, false
}
return i.registry.Products[position], true
}
// Resolve resolves either an exact canonical path or a normalized CLI path.
// No inference or fuzzy matching occurs in this layer.
func (i SchemaIndex) Resolve(path string) (ToolSpec, bool) {
path = strings.TrimSpace(path)
canonical := path
location, ok := i.byCanonical[canonical]
if !ok {
canonical, ok = i.byToolPath[path]
}
if !ok {
canonical, ok = i.byCLIPath[normalizeSchemaCLIPath(path)]
if !ok {
return ToolSpec{}, false
}
}
location = i.byCanonical[canonical]
return i.registry.Products[location.product].Tools[location.tool], true
}
// ResolveQuery adds compatibility for dotted and slash-separated CLI paths at
// the user-facing query boundary. Resolve remains strict because Registry
// validation uses it to detect missing canonical identities without falling
// through to a similarly spelled CLI path.
func (i SchemaIndex) ResolveQuery(path string) (ToolSpec, bool) {
if tool, ok := i.Resolve(path); ok {
return tool, true
}
canonical, ok := i.byCLIPath[normalizeSchemaQueryCLIPath(path)]
if !ok {
return ToolSpec{}, false
}
location := i.byCanonical[canonical]
return i.registry.Products[location.product].Tools[location.tool], true
}
// CanonicalPaths returns the complete tool identity set in stable order.
func (i SchemaIndex) CanonicalPaths() []string {
paths := make([]string, 0, len(i.byCanonical))
for path := range i.byCanonical {
paths = append(paths, path)
}
sort.Strings(paths)
return paths
}
// ToSnapshotPayload derives the progressive Catalog and full tool map from one
// normalized registry. This is the direct typed replacement for separately
// assembling runtimeToolSummary and runtimeToolPayload.
func (r SchemaRegistry) ToSnapshotPayload() (SchemaSnapshotPayload, error) {
index, err := r.Index()
if err != nil {
return SchemaSnapshotPayload{}, err
}
r = index.Registry()
products := make([]map[string]any, 0, len(r.Products))
tools := make(map[string]map[string]any, len(index.byCanonical))
for _, product := range r.Products {
summaries := make([]map[string]any, 0, len(product.Tools))
for _, tool := range product.Tools {
summary, summaryErr := snapshotToolSummary(tool)
if summaryErr != nil {
return SchemaSnapshotPayload{}, summaryErr
}
full, fullErr := snapshotToolPayload(tool)
if fullErr != nil {
return SchemaSnapshotPayload{}, fullErr
}
summaries = append(summaries, summary)
tools[tool.Identity.CanonicalPath] = full
}
productPayload, productErr := snapshotProductSummary(product)
if productErr != nil {
return SchemaSnapshotPayload{}, productErr
}
productPayload["tools"] = summaries
productPayload["tool_count"] = len(summaries)
products = append(products, productPayload)
}
catalog, err := r.registryEnvelopePayload(products, len(tools))
if err != nil {
return SchemaSnapshotPayload{}, err
}
return SchemaSnapshotPayload{Catalog: catalog, Tools: tools}, nil
}
// registryEnvelopePayload renders the shared Catalog envelope around prebuilt
// product payloads. The snapshot catalog and schema --all projection both use
// it, so the envelope keys cannot drift between the two views.
func (r SchemaRegistry) registryEnvelopePayload(products []map[string]any, toolCount int) (map[string]any, error) {
payload := map[string]any{}
payload["kind"] = defaultString(r.Kind, "schema")
payload["level"] = defaultString(r.Level, "catalog")
payload["count"] = len(products)
payload["tool_count"] = toolCount
payload["products"] = products
if r.Source != "" {
payload["source"] = r.Source
}
if err := putRawJSON(payload, "agent_metadata", r.AgentMetadata); err != nil {
return nil, fmt.Errorf("agent_metadata: %w", err)
}
return payload, nil
}
// Validate checks structural invariants only. It intentionally does not
// constrain precedence outcomes such as risk or required values.
func (t ToolSpec) Validate() error {
id := t.Identity
if id.ProductID == "" {
return fmt.Errorf("tool product_id is empty")
}
if id.Name == "" {
return fmt.Errorf("tool name is empty")
}
wantCanonical := id.ProductID + "." + id.Name
if id.CanonicalPath != wantCanonical {
return fmt.Errorf("tool canonical_path %q does not match %q", id.CanonicalPath, wantCanonical)
}
if id.CLIPath == "" {
return fmt.Errorf("tool %s cli_path is empty", id.CanonicalPath)
}
seen := make(map[string]bool, len(t.Parameters))
for _, parameter := range t.Parameters {
if parameter.Name == "" {
return fmt.Errorf("tool %s has parameter with empty name", id.CanonicalPath)
}
if seen[parameter.Name] {
return fmt.Errorf("tool %s has duplicate parameter %q", id.CanonicalPath, parameter.Name)
}
seen[parameter.Name] = true
if err := validateRawJSON(parameter.Default, "default", id.CanonicalPath, parameter.Name); err != nil {
return err
}
if err := validateRawJSON(parameter.InterfaceDefault, "interface_default", id.CanonicalPath, parameter.Name); err != nil {
return err
}
if err := validateRawJSON(parameter.Example, "example", id.CanonicalPath, parameter.Name); err != nil {
return err
}
}
if t.Interface.Ref != nil {
if strings.TrimSpace(t.Interface.Ref.ProductID) == "" || strings.TrimSpace(t.Interface.Ref.RPCName) == "" {
return fmt.Errorf("tool %s has incomplete interface_ref", id.CanonicalPath)
}
}
if t.DryRun != nil {
if err := t.DryRun.Validate(id.CanonicalPath); err != nil {
return err
}
}
if t.Result != nil {
if _, err := contract.NormalizeResultSpec(t.Result, id.CanonicalPath); err != nil {
return err
}
}
if t.Interface.Mode != "" || t.Interface.Availability != "" || t.Interface.Reason != "" || t.Interface.Ref != nil {
if err := t.Interface.Validate(id.CanonicalPath); err != nil {
return err
}
}
for field, provenance := range t.FieldProvenance {
if value, ok := t.provenanceValue(field); ok {
if err := validateFinalFieldProvenance(id.CanonicalPath, field, provenance, value); err != nil {
return err
}
}
}
for _, parameter := range t.Parameters {
for field, provenance := range parameter.FieldProvenance {
if value, ok := parameter.provenanceValue(field); ok {
if err := validateFinalFieldProvenance(id.CanonicalPath+" parameter "+parameter.Name, field, provenance, value); err != nil {
return err
}
}
}
}
return nil
}
func validateFinalFieldProvenance(owner, field string, provenance contract.FieldProvenance, finalValue any) error {
expected, err := json.Marshal(finalValue)
if err != nil {
return fmt.Errorf("%s field %s cannot encode final provenance value: %w", owner, field, err)
}
if len(provenance.Value) == 0 || !json.Valid(provenance.Value) || !equalJSONValues(provenance.Value, expected) {
return fmt.Errorf("%s field %s provenance winner does not equal final value: winner=%s final=%s", owner, field, string(provenance.Value), string(expected))
}
if strings.TrimSpace(provenance.Source) == "" || strings.TrimSpace(provenance.Precedence) == "" || strings.TrimSpace(provenance.Resolution) == "" {
return fmt.Errorf("%s field %s provenance winner is incomplete", owner, field)
}
if len(provenance.Candidates) == 0 {
return fmt.Errorf("%s field %s provenance has no candidates", owner, field)
}
selected := 0
for _, candidate := range provenance.Candidates {
if len(candidate.Value) == 0 || !json.Valid(candidate.Value) {
return fmt.Errorf("%s field %s has a provenance candidate with invalid value", owner, field)
}
if candidate.Selected == nil || !*candidate.Selected {
continue
}
selected++
if !json.Valid(candidate.Value) || !equalJSONValues(candidate.Value, expected) {
return fmt.Errorf("%s field %s selected provenance candidate does not equal final value", owner, field)
}
if strings.TrimSpace(candidate.Source) != strings.TrimSpace(provenance.Source) ||
strings.TrimSpace(candidate.Precedence) != strings.TrimSpace(provenance.Precedence) {
return fmt.Errorf("%s field %s selected provenance candidate disagrees with winner", owner, field)
}
}
for _, candidate := range provenance.OverriddenCandidates {
if len(candidate.Value) == 0 || !json.Valid(candidate.Value) {
return fmt.Errorf("%s field %s has an overridden provenance candidate with invalid value", owner, field)
}
if candidate.Selected != nil && *candidate.Selected {
return fmt.Errorf("%s field %s has a selected overridden provenance candidate", owner, field)
}
}
if selected != 1 {
return fmt.Errorf("%s field %s provenance has %d selected candidates, want 1", owner, field, selected)
}
return nil
}
// equalJSONValues compares decoded JSON types and values while preserving the
// lexical representation of numbers through json.Number. Formatting and
// equivalent string escapes are intentionally ignored; 1 and 1.0 remain
// distinct provenance values.
func equalJSONValues(left, right []byte) bool {
// Generated Schema values are already canonical JSON in the common path.
// Avoid allocating two decoded object graphs for identical bytes; keep the
// semantic decoder fallback for whitespace, escape, and representation
// differences. json.Valid preserves the previous behavior for equal but
// malformed input.
if bytes.Equal(left, right) {
return json.Valid(left)
}
decode := func(raw []byte) (any, bool) {
if !json.Valid(raw) {
return nil, false
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var value any
_ = decoder.Decode(&value)
return value, true
}
leftValue, leftOK := decode(left)
rightValue, rightOK := decode(right)
return leftOK && rightOK && reflect.DeepEqual(leftValue, rightValue)
}
func validateRawJSON(value json.RawMessage, field, canonical, parameter string) error {
if len(value) == 0 || json.Valid(value) {
return nil
}
return fmt.Errorf("tool %s parameter %q has invalid JSON %s", canonical, parameter, field)
}
// Sorted returns a detached, deterministic registry. Products are ordered by
// ID, tools by canonical path then CLI path, and parameters by CLI flag name.
func (r SchemaRegistry) Sorted() SchemaRegistry {
out := r
out.Products = append([]ProductSpec(nil), r.Products...)
for i := range out.Products {
out.Products[i] = out.Products[i].normalized()
}
sort.Slice(out.Products, func(i, j int) bool {
return out.Products[i].ID < out.Products[j].ID
})
return out
}
func (p ProductSpec) normalized() ProductSpec {
out := p
out.ID = strings.TrimSpace(out.ID)
out.Name = strings.TrimSpace(out.Name)
out.Tools = append([]ToolSpec(nil), p.Tools...)
for i := range out.Tools {
out.Tools[i] = out.Tools[i].normalized()
}
sort.Slice(out.Tools, func(i, j int) bool {
left, right := out.Tools[i].Identity, out.Tools[j].Identity
if left.CanonicalPath != right.CanonicalPath {
return left.CanonicalPath < right.CanonicalPath
}
return left.CLIPath < right.CLIPath
})
out.Selection = out.Selection.Normalized()
out.FieldProvenance = cloneFieldProvenance(out.FieldProvenance)
return out
}
func (p ProductSpec) provenanceValue(field string) (any, bool) {
switch field {
case "agent_summary":
return p.Selection.AgentSummary, true
case "use_when":
return p.Selection.UseWhen, true
case "avoid_when":
return p.Selection.AvoidWhen, true
default:
return nil, false
}
}
func (t ToolSpec) normalized() ToolSpec {
out := t
id := out.Identity
id.ProductID = strings.TrimSpace(id.ProductID)
id.SourceProductID = strings.TrimSpace(id.SourceProductID)
id.Name = strings.TrimSpace(id.Name)
id.CLIName = strings.TrimSpace(id.CLIName)
id.CanonicalPath = strings.TrimSpace(id.CanonicalPath)
if id.CanonicalPath == "" && id.ProductID != "" && id.Name != "" {
id.CanonicalPath = id.ProductID + "." + id.Name
}
id.Path = strings.TrimSpace(id.Path)
if id.Path == "" {
id.Path = id.CanonicalPath
}
id.CLIPath = normalizeSchemaCLIPath(id.CLIPath)
id.PrimaryCLIPath = normalizeSchemaCLIPath(id.PrimaryCLIPath)
if id.PrimaryCLIPath == "" {
id.PrimaryCLIPath = id.CLIPath
}
id.Group = strings.TrimSpace(id.Group)
id.Source = strings.TrimSpace(id.Source)
id.Aliases = sortedUniqueStrings(id.Aliases)
out.Identity = id
out.Parameters = append([]ParameterSpec(nil), t.Parameters...)
for i := range out.Parameters {
out.Parameters[i] = out.Parameters[i].normalized()
}
sort.Slice(out.Parameters, func(i, j int) bool {
return out.Parameters[i].Name < out.Parameters[j].Name
})
out.Constraints = normalizeRuntimeSchemaConstraints(t.Constraints)
if t.DryRun != nil {
dryRun := *t.DryRun
dryRun.PreviewKind = strings.TrimSpace(dryRun.PreviewKind)
out.DryRun = &dryRun
}
if t.Result != nil {
result, err := contract.NormalizeResultSpec(t.Result, id.CanonicalPath)
if err == nil {
out.Result = result
}
}
out.Positionals = append([]contract.RuntimeSchemaPositional(nil), t.Positionals...)
sort.Slice(out.Positionals, func(i, j int) bool {
if out.Positionals[i].Index != out.Positionals[j].Index {
return out.Positionals[i].Index < out.Positionals[j].Index
}
return out.Positionals[i].Name < out.Positionals[j].Name
})
out.Selection = out.Selection.Normalized()
return out
}
func (p ParameterSpec) normalized() ParameterSpec {
out := p
out.Name = strings.TrimSpace(out.Name)
out.Type = strings.TrimSpace(out.Type)
out.Property = strings.TrimSpace(out.Property)
out.Enum = stableUniqueStrings(out.Enum)
return out
}
func stableUniqueStrings(values []string) []string {
if values == nil {
return nil
}
seen := make(map[string]bool, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}
// cloneOptionalStrings preserves the semantic difference between an omitted
// list (nil) and an explicitly authored empty list (non-nil, length zero).
// Selection precedence treats [] as a real winner, so that presence must
// survive the generated metadata adapter and every typed projection.
func cloneOptionalStrings(values []string) []string {
if values == nil {
return nil
}
out := make([]string, len(values))
copy(out, values)
return out
}
func sortedUniqueStrings(values []string) []string {
seen := make(map[string]bool, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
sort.Strings(out)
if len(out) == 0 {
return nil
}
return out
}
// ToPayload renders the compatible flat schema payload after deterministically
// sorting the typed model.
func (r SchemaRegistry) ToPayload() (map[string]any, error) {
index, err := r.Index()
if err != nil {
return nil, err
}
r = index.Registry()
products := make([]map[string]any, 0, len(r.Products))
toolCount := 0
for _, product := range r.Products {
payload, err := product.ToPayload()
if err != nil {
return nil, err
}
products = append(products, payload)
toolCount += len(product.Tools)
}
return r.registryEnvelopePayload(products, toolCount)
}
// ToOverviewPayload renders the small first-hop product index directly from
// the resolved registry. It is a presentation projection only: it never reads
// Agent metadata or any other source after assembly.
func (r SchemaRegistry) ToOverviewPayload() (map[string]any, error) {
index, err := r.Index()
if err != nil {
return nil, err
}
r = index.Registry()
products := make([]map[string]any, 0, len(r.Products))
toolCount := 0
for _, product := range r.Products {
entry := map[string]any{
"id": product.ID,
"tool_count": len(product.Tools),
"schema_path": product.ID,
}
if summary := strings.TrimSpace(product.Selection.AgentSummary); summary != "" {
entry["agent_summary"] = summary
} else if len(product.Selection.UseWhen) > 0 {
entry["use_when"] = []string{product.Selection.UseWhen[0]}
} else if product.Description != "" {
entry["description"] = product.Description
}
products = append(products, entry)
toolCount += len(product.Tools)
}
payload := map[string]any{
"kind": defaultString(r.Kind, "schema"),
"level": "products",
"count": len(products),
"tool_count": toolCount,
"products": products,
}
if r.Source != "" {
payload["source"] = r.Source
}
if err := putRawJSON(payload, "agent_metadata", r.AgentMetadata); err != nil {
return nil, fmt.Errorf("agent_metadata: %w", err)
}
return payload, nil
}
// ToPayload renders one product and its full tools.
func (p ProductSpec) ToPayload() (map[string]any, error) {
return p.envelopePayload(ToolSpec.ToPayload)
}
// ToSummaryPayload renders one product with progressive tool summaries. Both
// this view and the full product payload are projections of the same ToolSpec
// slice; neither re-resolves annotations or metadata.
func (p ProductSpec) ToSummaryPayload() (map[string]any, error) {
return p.envelopePayload(ToolSpec.ToSummaryPayload)
}
// envelopePayload renders the product envelope around tools rendered by
// renderTool. The full and summary product payloads differ only in the tool
// renderer, which keeps the envelope keys byte-identical across projections.
func (p ProductSpec) envelopePayload(renderTool func(ToolSpec) (map[string]any, error)) (map[string]any, error) {
p = p.normalized()
tools := make([]map[string]any, 0, len(p.Tools))
for _, tool := range p.Tools {
payload, err := renderTool(tool)
if err != nil {
return nil, err
}
tools = append(tools, payload)
}
payload := map[string]any{}
payload["id"] = p.ID
payload["name"] = p.Name
payload["description"] = p.Description
payload["tool_count"] = len(tools)
payload["tools"] = tools
if p.Runtime {
payload["runtime"] = true
}
applySelectionPayload(payload, p.Selection, false)
if len(p.FieldProvenance) > 0 {
value, valueErr := typedJSONValue(p.FieldProvenance)
if valueErr != nil {
return nil, valueErr
}
payload["field_provenance"] = value
}
return payload, nil
}
// ToPayload renders one full leaf contract in the existing flat JSON shape.
func (t ToolSpec) ToPayload() (map[string]any, error) {
t = t.normalized()
if err := t.Validate(); err != nil {
return nil, err
}
payload := map[string]any{}
id := t.Identity
payload["name"] = id.Name
payload["cli_name"] = id.CLIName
payload["canonical_path"] = id.CanonicalPath
payload["path"] = id.Path
payload["cli_path"] = id.CLIPath
payload["primary_cli_path"] = id.PrimaryCLIPath
payload["is_alias"] = id.IsAlias
payload["source"] = id.Source
payload["product_id"] = id.ProductID
payload["display"] = t.Display
payload["title"] = t.Title
payload["description"] = t.Description
setOptionalString(payload, "source_product_id", id.SourceProductID)
setOptionalString(payload, "group", id.Group)
setOptionalString(payload, "metadata_source", t.MetadataSource)
if len(id.Aliases) > 0 {
payload["aliases"] = append([]string(nil), id.Aliases...)
}
parameters := make(map[string]any, len(t.Parameters))
for _, parameter := range t.Parameters {
entry, parameterErr := parameter.ToPayload()
if parameterErr != nil {
return nil, fmt.Errorf("render %s parameter %s: %w", id.CanonicalPath, parameter.Name, parameterErr)
}
parameters[parameter.Name] = entry
}
payload["parameters"] = parameters
payload["has_parameters"] = len(parameters) > 0
payload["parameter_count"] = len(parameters)
if !runtimeSchemaConstraintsEmpty(t.Constraints) {
value, _ := typedJSONValue(t.Constraints)
payload["constraints"] = value
}
if len(t.Positionals) > 0 {
value, _ := typedJSONValue(t.Positionals)
payload["positionals"] = value
}
if t.DryRun != nil {
value, _ := typedJSONValue(t.DryRun)
payload["dry_run"] = value
}
if t.Result != nil {
value, _ := typedJSONValue(t.Result)
payload["result"] = value
}
applySafetyPayload(payload, t.Safety)
applyInterfacePayload(payload, t.Interface)
applySelectionPayload(payload, t.Selection, true)
if len(t.FieldProvenance) > 0 {
value, valueErr := typedJSONValue(t.FieldProvenance)
if valueErr != nil {
return nil, valueErr
}
payload["field_provenance"] = value
}
return payload, nil
}
// ToSummaryPayload projects a ToolSpec without parameters and leaf-only
// execution detail. It is derived from the same typed ToolSpec, never assembled
// independently.
func (t ToolSpec) ToSummaryPayload() (map[string]any, error) {
payload, err := t.ToPayload()
if err != nil {
return nil, err
}
for _, key := range []string{
"parameters", "has_parameters", "parameter_count", "constraints",
"positionals", "result", "examples", "effect_source", "agent_source_refs",
"field_provenance", "path", "source", "product_id", "display", "is_alias",
} {
delete(payload, key)
}
return payload, nil
}
// ToPayload renders one parameter in the existing parameters.<flag> shape.
func (p ParameterSpec) ToPayload() (map[string]any, error) {
p = p.normalized()
payload := map[string]any{}
payload["type"] = p.Type
payload["description"] = p.Description
payload["required"] = p.Required
setOptionalString(payload, "property", p.Property)
if p.CLIRequired {
payload["cli_required"] = true
}
setOptionalString(payload, "required_when", p.RequiredWhen)
if err := putRawJSON(payload, "default", p.Default); err != nil {
return nil, err
}
if err := putRawJSON(payload, "interface_default", p.InterfaceDefault); err != nil {
return nil, err
}
if err := putRawJSON(payload, "example", p.Example); err != nil {
return nil, err
}
setOptionalString(payload, "format", p.Format)
if len(p.Enum) > 0 {
payload["enum"] = append([]string(nil), p.Enum...)
}
setOptionalString(payload, "interface_description", p.InterfaceDescription)
setOptionalString(payload, "interface_type", p.InterfaceType)
if len(p.FieldProvenance) > 0 {
value, valueErr := typedJSONValue(p.FieldProvenance)
if valueErr != nil {
return nil, valueErr
}
payload["field_provenance"] = value
}
return payload, nil
}
func applySafetyPayload(payload map[string]any, safety contract.SafetySpec) {
setOptionalString(payload, "effect", safety.Effect)
setOptionalString(payload, "effect_source", safety.EffectSource)
setOptionalString(payload, "risk", safety.Risk)
setOptionalString(payload, "confirmation", safety.Confirmation)
setOptionalString(payload, "idempotency", safety.Idempotency)
}
func applyInterfacePayload(payload map[string]any, spec contract.InterfaceSpec) {
if spec.Ref != nil {
payload["interface_ref"] = map[string]any{
"product_id": spec.Ref.ProductID,
"rpc_name": spec.Ref.RPCName,
}
}
setOptionalString(payload, "interface_mode", spec.Mode)
setOptionalString(payload, "availability", spec.Availability)
setOptionalString(payload, "interface_reason", spec.Reason)
}
func applySelectionPayload(payload map[string]any, selection contract.SelectionSpec, full bool) {
setOptionalString(payload, "agent_summary", selection.AgentSummary)
setOptionalString(payload, "agent_summary_source", selection.AgentSummarySource)
setOptionalStrings(payload, "use_when", selection.UseWhen)
setOptionalStrings(payload, "avoid_when", selection.AvoidWhen)
if full {
setOptionalStrings(payload, "prerequisites", selection.Prerequisites)
setOptionalStrings(payload, "tips", selection.Tips)
setOptionalStrings(payload, "workflow_refs", selection.WorkflowRefs)
setOptionalStrings(payload, "examples", selection.Examples)
if selection.Reviewed != nil {
payload["reviewed"] = *selection.Reviewed
}
}
setOptionalStrings(payload, "agent_source_refs", selection.SourceRefs)
setOptionalString(payload, "agent_metadata_source", selection.MetadataSource)
}
func setOptionalString(payload map[string]any, key, value string) {
if value = strings.TrimSpace(value); value != "" {
payload[key] = value
}
}
func setOptionalStrings(payload map[string]any, key string, values []string) {
if values = stableUniqueStrings(values); values != nil {
payload[key] = values
}
}
func defaultString(value, fallback string) string {
if value = strings.TrimSpace(value); value != "" {
return value
}
return fallback
}
func putRawJSON(payload map[string]any, key string, raw json.RawMessage) error {
if len(raw) == 0 {
return nil
}
value, err := rawJSONValue(raw)
if err != nil {
return err
}
payload[key] = value
return nil
}
func rawJSONValue(raw json.RawMessage) (any, error) {
if !json.Valid(raw) {
return nil, fmt.Errorf("invalid JSON value")
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var value any
_ = decoder.Decode(&value)
return value, nil
}
func typedJSONValue(value any) (any, error) {
data, err := json.Marshal(value)
if err != nil {
return nil, err
}
return rawJSONValue(data)
}