From 8f60df6a3f120510f11af9082078786de7e03486 Mon Sep 17 00:00:00 2001 From: josegironn <30703536+josegironn@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:29:14 -0700 Subject: [PATCH 1/2] feat(project): compile bindings.json + agent connectors/apps/skills (MAJ-290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bindings.json (project root) maps slot aliases to platform ids using the shared manifest shape; agent.json gains connectors/apps (with nested permissions) and skills (local slug or binding slot). Compile validates every reference against a binding or a local skill — including slot/slug collisions — and emits the facets plus normalized bindings in the compiled config. Vendored agent/bindings schemas match the server-generated bytes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0185ReH8ZKtT8jUjUtT1ci1X Co-authored-by: Orca --- .github/workflows/schemas-drift.yml | 4 +- Makefile | 5 +- projects/bindings.go | 212 ++++++++++++ projects/bindings_test.go | 471 ++++++++++++++++++++++++++ projects/compile.go | 4 + projects/load.go | 166 ++++++++- projects/load_test.go | 15 +- projects/schemas.go | 28 +- projects/schemas/SCHEMAS.sha256 | 7 +- projects/schemas/agent.schema.json | 61 ++++ projects/schemas/bindings.schema.json | 65 ++++ projects/schemas_test.go | 1 + projects/types.go | 87 ++++- 13 files changed, 1087 insertions(+), 39 deletions(-) create mode 100644 projects/bindings.go create mode 100644 projects/bindings_test.go create mode 100644 projects/schemas/bindings.schema.json diff --git a/.github/workflows/schemas-drift.yml b/.github/workflows/schemas-drift.yml index 57b9586..60fe712 100644 --- a/.github/workflows/schemas-drift.yml +++ b/.github/workflows/schemas-drift.yml @@ -15,14 +15,14 @@ jobs: steps: - uses: actions/checkout@v4 # TODO: drop the "not live yet" skip-with-warning branch below and make - # this job strict once prod serves GET /schemas/{project,agent}.json. + # this job strict once prod serves GET /schemas/{project,agent,bindings}.json. - name: Check vendored schemas against the platform API env: MAJOR_SCHEMAS_BASE_URL: https://api.major.tech run: | set -e status=0 - for pair in "project:projects/schemas/project.schema.json" "agent:projects/schemas/agent.schema.json"; do + for pair in "project:projects/schemas/project.schema.json" "agent:projects/schemas/agent.schema.json" "bindings:projects/schemas/bindings.schema.json"; do name="${pair%%:*}" vendored="${pair##*:}" url="$MAJOR_SCHEMAS_BASE_URL/schemas/$name.json" diff --git a/Makefile b/Makefile index 35d06de..5f4ab48 100644 --- a/Makefile +++ b/Makefile @@ -50,7 +50,7 @@ release: # Vendor-sync projects/schemas/*.schema.json from the platform API. mono-builder # is the source of truth: it generates these schemas from zod and serves them at -# GET /schemas/project.json and GET /schemas/agent.json. Refresh the +# GET /schemas/{project,agent,bindings}.json. Refresh the # vendored copy and its SCHEMAS.sha256 manifest after an upstream schema change. # Override the source with MAJOR_SCHEMAS_BASE_URL, e.g. against a local dev API: # MAJOR_SCHEMAS_BASE_URL=http://localhost:3301 make sync-schemas @@ -59,9 +59,10 @@ sync-schemas: echo "Syncing schemas from $$base..."; \ curl -fsSL "$$base/schemas/project.json" -o projects/schemas/project.schema.json; \ curl -fsSL "$$base/schemas/agent.json" -o projects/schemas/agent.schema.json; \ + curl -fsSL "$$base/schemas/bindings.json" -o projects/schemas/bindings.schema.json; \ { \ echo "source: $$base"; \ echo "fetched: $$(date -u +%Y-%m-%dT%H:%M:%SZ)"; \ - shasum -a 256 projects/schemas/project.schema.json projects/schemas/agent.schema.json; \ + shasum -a 256 projects/schemas/project.schema.json projects/schemas/agent.schema.json projects/schemas/bindings.schema.json; \ } > projects/schemas/SCHEMAS.sha256; \ echo "Done. Vendored schemas + projects/schemas/SCHEMAS.sha256 written." diff --git a/projects/bindings.go b/projects/bindings.go new file mode 100644 index 0000000..12e2c4a --- /dev/null +++ b/projects/bindings.go @@ -0,0 +1,212 @@ +package projects + +// bindings.json parsing plus the compile-time reference validation that hangs +// off it. bindings.json is the human-authored slot -> platform-id manifest at +// the project root, next to project.json: agents reference platform resources, +// applications, and skills by author-chosen slot names, and this file is where +// those slots get their concrete ids. Compile only validates that every +// reference resolves - it never bakes ids into agent configs; deploy resolves +// slots to ids server-side. + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" +) + +// BindingsFileName is the project-root manifest compile reads slots from. +const BindingsFileName = "bindings.json" + +// rawBindings mirrors bindings.json. The schema (schemas/bindings.schema.json) +// enforces slot-name charset, uuid format, and non-empty resource types; this +// only has to carry the values through. +type rawBindings struct { + Slots BindingSlots `json:"slots"` +} + +// loadBindings reads and validates /bindings.json. A missing file is not +// an error (the manifest is optional): it returns nil bindings and no issues, +// and reference validation then tells the author to add the file only if some +// agent actually references a slot. +func loadBindings(dir string) (*BindingSlots, []Issue) { + path := filepath.Join(dir, BindingsFileName) + + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + + return nil, []Issue{{File: BindingsFileName, Message: "cannot read " + BindingsFileName + ": " + err.Error()}} + } + + if issues := validateAgainst(BindingsSchema(), raw, BindingsFileName); len(issues) > 0 { + return nil, issues + } + + var parsed rawBindings + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, []Issue{{File: BindingsFileName, Message: "invalid JSON: " + err.Error()}} + } + + return &parsed.Slots, nil +} + +// checkSkillSlotCollisions rejects a bindings skills slot whose name is also a +// project-local skill slug. The server resolves the skill namespace by merging +// bindings slots with project skill slugs and letting the local slug win, so a +// collision would silently ignore the binding. Fail the compile instead and +// tell the author to rename the slot. +func checkSkillSlotCollisions(bindings *BindingSlots, skillSlugs map[string]bool) []Issue { + if bindings == nil { + return nil + } + + var issues []Issue + + for _, slot := range idSlotNames(bindings.Skills) { + if !skillSlugs[slot] { + continue + } + + issues = append(issues, Issue{ + File: BindingsFileName, + Path: "/slots/skills/" + slot, + Message: fmt.Sprintf("skills slot %q collides with the project skill of the same slug; rename the binding slot, because a project-local skill always wins over a bound platform skill", slot), + }) + } + + return issues +} + +// validateAgentReferences checks that every slot an agent references exists in +// the project's bindings, and that every bare skill string names a skill +// directory compiled in this run. bindings is nil when the project has no +// bindings.json, in which case any slot reference is reported as a missing +// manifest rather than a missing slot. srcDir only names the directory a +// missing project-local skill would have to live in. +func validateAgentReferences(agent AgentDefinition, bindings *BindingSlots, skillSlugs map[string]bool, srcDir string) []Issue { + agentFile := filepath.Join(agent.Dir, "agent.json") + + var issues []Issue + + for i, connector := range agent.Connectors { + path := fmt.Sprintf("/connectors/%d/slot", i) + + if issue := checkSlot(agentFile, path, connector.Slot, "resources", bindings, resourceSlotNames(bindings)); issue != nil { + issues = append(issues, *issue) + } + } + + for i, app := range agent.Apps { + path := fmt.Sprintf("/apps/%d/slot", i) + + if issue := checkSlot(agentFile, path, app.Slot, "applications", bindings, idSlotNames(applicationBindings(bindings))); issue != nil { + issues = append(issues, *issue) + } + } + + for i, skill := range agent.Skills { + if skill.Slot != "" { + path := fmt.Sprintf("/skills/%d/slot", i) + + if issue := checkSlot(agentFile, path, skill.Slot, "skills", bindings, idSlotNames(skillBindings(bindings))); issue != nil { + issues = append(issues, *issue) + } + + continue + } + + if !skillSlugs[skill.Slug] { + issues = append(issues, Issue{ + File: agentFile, + Path: fmt.Sprintf("/skills/%d", i), + Message: fmt.Sprintf("skill %q is not a skill in this project; add %s/SKILL.md, or reference a platform skill with {\"slot\": ...}", skill.Slug, filepath.ToSlash(filepath.Join(srcDir, "skills", skill.Slug))), + }) + } + } + + return issues +} + +// checkSlot reports the one right issue for a slot reference: the manifest is +// missing entirely, or the slot is absent from the given kind. +func checkSlot(agentFile, path, slot, kind string, bindings *BindingSlots, known []string) *Issue { + if bindings == nil { + return &Issue{ + File: agentFile, + Path: path, + Message: fmt.Sprintf("references binding slot %q but this project has no %s; add %s at the project root with slots.%s.%s", slot, BindingsFileName, BindingsFileName, kind, slot), + } + } + + for _, name := range known { + if name == slot { + return nil + } + } + + return &Issue{ + File: agentFile, + Path: path, + Message: fmt.Sprintf("unknown binding slot %q: %s declares no slots.%s.%s (declared: %s)", slot, BindingsFileName, kind, slot, describeSlots(known)), + } +} + +func describeSlots(names []string) string { + if len(names) == 0 { + return "none" + } + + out := "" + for i, name := range names { + if i > 0 { + out += ", " + } + out += fmt.Sprintf("%q", name) + } + + return out +} + +func resourceSlotNames(bindings *BindingSlots) []string { + if bindings == nil { + return nil + } + + names := make([]string, 0, len(bindings.Resources)) + for name := range bindings.Resources { + names = append(names, name) + } + sort.Strings(names) + + return names +} + +func applicationBindings(bindings *BindingSlots) map[string]IDBinding { + if bindings == nil { + return nil + } + + return bindings.Applications +} + +func skillBindings(bindings *BindingSlots) map[string]IDBinding { + if bindings == nil { + return nil + } + + return bindings.Skills +} + +func idSlotNames(entries map[string]IDBinding) []string { + names := make([]string, 0, len(entries)) + for name := range entries { + names = append(names, name) + } + sort.Strings(names) + + return names +} diff --git a/projects/bindings_test.go b/projects/bindings_test.go new file mode 100644 index 0000000..75efa58 --- /dev/null +++ b/projects/bindings_test.go @@ -0,0 +1,471 @@ +package projects + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +const ( + testResourceID = "11111111-1111-4111-8111-111111111111" + testApplicationID = "22222222-2222-4222-8222-222222222222" + testSkillID = "33333333-3333-4333-8333-333333333333" +) + +// writeBindings writes a bindings.json body into the project root. +func writeBindings(t *testing.T, dir, body string) { + t.Helper() + + if err := os.WriteFile(filepath.Join(dir, BindingsFileName), []byte(body), 0644); err != nil { + t.Fatal(err) + } +} + +// writeAgent writes an agent.json under /src/agents//. +func writeAgent(t *testing.T, dir, slug, body string) { + t.Helper() + + agentDir := filepath.Join(dir, "src", "agents", slug) + if err := os.MkdirAll(agentDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(agentDir, "agent.json"), []byte(body), 0644); err != nil { + t.Fatal(err) + } +} + +// writeSkill writes a minimal valid skill under /src/skills//. +func writeSkill(t *testing.T, dir, slug string) { + t.Helper() + + skillDir := filepath.Join(dir, "src", "skills", slug) + if err := os.MkdirAll(skillDir, 0755); err != nil { + t.Fatal(err) + } + content := "---\nname: " + slug + "\ndescription: A test skill.\n---\nBody.\n" + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +const fullBindings = `{ + "$schema": "https://schemas.major.tech/bindings.json", + "slots": { + "resources": { "prod-db": { "type": "postgresql", "id": "` + testResourceID + `" } }, + "applications": { "crm": { "id": "` + testApplicationID + `" } }, + "skills": { "reporting": { "id": "` + testSkillID + `" } } + } +}` + +const fullAgent = `{ + "slug": "triage", + "name": "Triage", + "systemPrompt": "You triage.", + "connectors": [{ "slot": "prod-db", "permissions": [{ "tool": "postgresql_psql", "decision": "always_allow" }] }], + "apps": [{ "slot": "crm", "permissions": [{ "method": "POST", "path": "/api/orders", "decision": "ask" }] }], + "skills": ["pdf-tools", { "slot": "reporting" }] +}` + +// TestCompileFullAgentWithBindings is the happy path: all three facets on one +// agent, every reference resolving through bindings.json or a project-local +// skill directory. +func TestCompileFullAgentWithBindings(t *testing.T) { + dir := t.TempDir() + writeFixtureProject(t, dir) + writeBindings(t, dir, fullBindings) + writeSkill(t, dir, "pdf-tools") + writeAgent(t, dir, "triage", fullAgent) + initGitRepo(t, dir) + + res, issues := Compile(dir) + if len(issues) != 0 { + t.Fatalf("expected no issues, got: %+v", issues) + } + + if res.Config.Bindings == nil { + t.Fatal("expected bindings in the compiled config") + } + if got := res.Config.Bindings.Resources["prod-db"]; got.Type != "postgresql" || got.ID != testResourceID { + t.Fatalf("resource binding = %+v", got) + } + if got := res.Config.Bindings.Applications["crm"].ID; got != testApplicationID { + t.Fatalf("application binding id = %q", got) + } + if got := res.Config.Bindings.Skills["reporting"].ID; got != testSkillID { + t.Fatalf("skill binding id = %q", got) + } + + agent := res.Config.Agents[0] + if len(agent.Connectors) != 1 || agent.Connectors[0].Slot != "prod-db" { + t.Fatalf("connectors = %+v", agent.Connectors) + } + if p := agent.Connectors[0].Permissions; len(p) != 1 || p[0].Tool != "postgresql_psql" || p[0].Decision != "always_allow" { + t.Fatalf("connector permissions = %+v", p) + } + if len(agent.Apps) != 1 || agent.Apps[0].Slot != "crm" { + t.Fatalf("apps = %+v", agent.Apps) + } + if p := agent.Apps[0].Permissions; len(p) != 1 || p[0].Method != "POST" || p[0].Path != "/api/orders" || p[0].Decision != "ask" { + t.Fatalf("app permissions = %+v", p) + } + + // A bare string normalizes to {"slug": ...}; a slot ref stays {"slot": ...}. + want := []AgentSkillRef{{Slug: "pdf-tools"}, {Slot: "reporting"}} + if len(agent.Skills) != len(want) { + t.Fatalf("skills = %+v", agent.Skills) + } + for i := range want { + if agent.Skills[i] != want[i] { + t.Fatalf("skills[%d] = %+v, want %+v", i, agent.Skills[i], want[i]) + } + } + + var raw map[string]any + if err := json.Unmarshal(res.ConfigJSON, &raw); err != nil { + t.Fatalf("compiled JSON does not parse: %v", err) + } + if _, ok := raw["bindings"]; !ok { + t.Fatalf("expected a bindings key in the compiled JSON: %s", res.ConfigJSON) + } + if !strings.Contains(string(res.ConfigJSON), `"skills":[{"slug":"pdf-tools"},{"slot":"reporting"}]`) { + t.Fatalf("skills not normalized as expected: %s", res.ConfigJSON) + } +} + +// TestCompileOmitsEmptyBindingKinds checks that a bindings.json declaring only +// resources emits only that kind, not empty applications/skills objects. +func TestCompileOmitsEmptyBindingKinds(t *testing.T) { + dir := t.TempDir() + writeFixtureProject(t, dir) + writeBindings(t, dir, `{"slots":{"resources":{"prod-db":{"type":"postgresql","id":"`+testResourceID+`"}}}}`) + + res, issues := Compile(dir) + if len(issues) != 0 { + t.Fatalf("expected no issues, got: %+v", issues) + } + + if !strings.Contains(string(res.ConfigJSON), `"bindings":{"resources":`) { + t.Fatalf("expected a resources-only bindings block: %s", res.ConfigJSON) + } + if strings.Contains(string(res.ConfigJSON), `"applications"`) || strings.Contains(string(res.ConfigJSON), `"skills"`) { + t.Fatalf("expected empty binding kinds to be omitted: %s", res.ConfigJSON) + } +} + +// TestCompileWithoutBindingsFileOmitsBindingsKey pins backward compatibility: +// a project that predates bindings.json compiles to exactly the bytes it did +// before, with no bindings key. +func TestCompileWithoutBindingsFileOmitsBindingsKey(t *testing.T) { + res, issues := Compile("testdata/valid") + if len(issues) != 0 { + t.Fatalf("expected no issues, got: %+v", issues) + } + + if res.Config.Bindings != nil { + t.Fatalf("expected nil bindings, got: %+v", res.Config.Bindings) + } + if strings.Contains(string(res.ConfigJSON), `"bindings"`) { + t.Fatalf("expected no bindings key in compiled JSON, got: %s", res.ConfigJSON) + } + for _, key := range []string{`"connectors"`, `"apps"`, `"skills"`} { + if strings.Contains(string(res.ConfigJSON), key) { + t.Fatalf("expected no %s key for an old project, got: %s", key, res.ConfigJSON) + } + } +} + +func TestCompileUnknownConnectorSlotRejected(t *testing.T) { + dir := t.TempDir() + writeFixtureProject(t, dir) + writeBindings(t, dir, fullBindings) + writeAgent(t, dir, "triage", `{"slug":"triage","name":"T","systemPrompt":"x","connectors":[{"slot":"staging-db"}]}`) + + _, issues := Compile(dir) + if !findIssue(issues, `unknown binding slot "staging-db"`) || !findIssue(issues, "slots.resources") { + t.Fatalf("expected an unknown resource-slot issue, got: %+v", issues) + } +} + +func TestCompileUnknownAppSlotRejected(t *testing.T) { + dir := t.TempDir() + writeFixtureProject(t, dir) + writeBindings(t, dir, fullBindings) + writeAgent(t, dir, "triage", `{"slug":"triage","name":"T","systemPrompt":"x","apps":[{"slot":"billing"}]}`) + + _, issues := Compile(dir) + if !findIssue(issues, `unknown binding slot "billing"`) || !findIssue(issues, "slots.applications") { + t.Fatalf("expected an unknown application-slot issue, got: %+v", issues) + } +} + +func TestCompileUnknownSkillSlotRejected(t *testing.T) { + dir := t.TempDir() + writeFixtureProject(t, dir) + writeBindings(t, dir, fullBindings) + writeAgent(t, dir, "triage", `{"slug":"triage","name":"T","systemPrompt":"x","skills":[{"slot":"forecasting"}]}`) + + _, issues := Compile(dir) + if !findIssue(issues, `unknown binding slot "forecasting"`) || !findIssue(issues, "slots.skills") { + t.Fatalf("expected an unknown skill-slot issue, got: %+v", issues) + } +} + +func TestCompileUnknownProjectSkillRejected(t *testing.T) { + dir := t.TempDir() + writeFixtureProject(t, dir) + writeSkill(t, dir, "pdf-tools") + writeAgent(t, dir, "triage", `{"slug":"triage","name":"T","systemPrompt":"x","skills":["ocr-tools"]}`) + initGitRepo(t, dir) + + _, issues := Compile(dir) + if !findIssue(issues, `skill "ocr-tools" is not a skill in this project`) { + t.Fatalf("expected an unresolved project-skill issue, got: %+v", issues) + } +} + +// TestCompileSlotReferenceWithoutBindingsFile checks the no-manifest case gets +// its own message telling the author to add bindings.json, not a confusing +// "unknown slot" against an empty manifest. +func TestCompileSlotReferenceWithoutBindingsFile(t *testing.T) { + dir := t.TempDir() + writeFixtureProject(t, dir) + writeAgent(t, dir, "triage", `{"slug":"triage","name":"T","systemPrompt":"x","connectors":[{"slot":"prod-db"}]}`) + + _, issues := Compile(dir) + if !findIssue(issues, "this project has no bindings.json") { + t.Fatalf("expected a missing-bindings.json issue, got: %+v", issues) + } +} + +func TestCompileInvalidBindingsRejected(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + {"malformed JSON", `{"slots":`, "invalid JSON"}, + {"unknown top-level key", `{"slots":{},"extra":1}`, "extra"}, + {"unknown slot kind", `{"slots":{"widgets":{}}}`, "widgets"}, + {"non-uuid id", `{"slots":{"applications":{"crm":{"id":"not-a-uuid"}}}}`, "pattern"}, + {"empty resource type", `{"slots":{"resources":{"db":{"type":"","id":"` + testResourceID + `"}}}}`, "minLength"}, + {"bad slot charset", `{"slots":{"applications":{"Bad_Slot":{"id":"` + testApplicationID + `"}}}}`, "pattern"}, + {"unknown entry key", `{"slots":{"applications":{"crm":{"id":"` + testApplicationID + `","name":"x"}}}}`, "name"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + writeFixtureProject(t, dir) + writeBindings(t, dir, tt.body) + + _, issues := Compile(dir) + if len(issues) == 0 { + t.Fatal("expected an issue") + } + if issues[0].File != BindingsFileName { + t.Fatalf("issue not attributed to %s: %+v", BindingsFileName, issues) + } + if !findIssue(issues, tt.want) { + t.Fatalf("expected an issue mentioning %q, got: %+v", tt.want, issues) + } + }) + } +} + +func TestCompileDuplicateFacetEntriesRejected(t *testing.T) { + tests := []struct { + name string + agent string + want string + }{ + { + name: "duplicate connector slot", + agent: `{"slug":"a","name":"A","systemPrompt":"x","connectors":[{"slot":"prod-db"},{"slot":"prod-db"}]}`, + want: `duplicate connector slot "prod-db"`, + }, + { + name: "duplicate app slot", + agent: `{"slug":"a","name":"A","systemPrompt":"x","apps":[{"slot":"crm"},{"slot":"crm"}]}`, + want: `duplicate app slot "crm"`, + }, + { + name: "duplicate connector tool", + agent: `{"slug":"a","name":"A","systemPrompt":"x","connectors":[{"slot":"prod-db","permissions":[{"tool":"t","decision":"ask"},{"tool":"t","decision":"always_deny"}]}]}`, + want: `duplicate permission for tool "t"`, + }, + { + name: "duplicate app endpoint", + agent: `{"slug":"a","name":"A","systemPrompt":"x","apps":[{"slot":"crm","permissions":[{"method":"GET","path":"/x","decision":"ask"},{"method":"GET","path":"/x","decision":"always_allow"}]}]}`, + want: "duplicate permission for GET /x", + }, + { + name: "duplicate skill slot", + agent: `{"slug":"a","name":"A","systemPrompt":"x","skills":[{"slot":"reporting"},{"slot":"reporting"}]}`, + want: `duplicate reference to skill slot "reporting"`, + }, + { + name: "duplicate project skill", + agent: `{"slug":"a","name":"A","systemPrompt":"x","skills":["pdf-tools","pdf-tools"]}`, + want: `duplicate reference to project skill "pdf-tools"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + writeFixtureProject(t, dir) + writeBindings(t, dir, fullBindings) + writeSkill(t, dir, "pdf-tools") + writeAgent(t, dir, "a", tt.agent) + initGitRepo(t, dir) + + _, issues := Compile(dir) + if !findIssue(issues, tt.want) { + t.Fatalf("expected an issue mentioning %q, got: %+v", tt.want, issues) + } + }) + } +} + +func TestCompileInvalidAgentFacetShapeRejected(t *testing.T) { + tests := []struct { + name string + agent string + want string + }{ + { + name: "unknown decision", + agent: `{"slug":"a","name":"A","systemPrompt":"x","connectors":[{"slot":"prod-db","permissions":[{"tool":"t","decision":"maybe"}]}]}`, + want: "must be one of", + }, + { + name: "connector without slot", + agent: `{"slug":"a","name":"A","systemPrompt":"x","connectors":[{"permissions":[]}]}`, + want: "slot", + }, + { + name: "app permission without path", + agent: `{"slug":"a","name":"A","systemPrompt":"x","apps":[{"slot":"crm","permissions":[{"method":"GET","decision":"ask"}]}]}`, + want: "path", + }, + { + name: "unknown key on a connector", + agent: `{"slug":"a","name":"A","systemPrompt":"x","connectors":[{"slot":"prod-db","upstream":true}]}`, + want: "upstream", + }, + { + name: "skill ref that is neither a string nor a slot object", + agent: `{"slug":"a","name":"A","systemPrompt":"x","skills":[{"slug":"pdf-tools"}]}`, + want: "slug", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + writeFixtureProject(t, dir) + writeBindings(t, dir, fullBindings) + writeAgent(t, dir, "a", tt.agent) + + _, issues := Compile(dir) + if !findIssue(issues, tt.want) { + t.Fatalf("expected an issue mentioning %q, got: %+v", tt.want, issues) + } + }) + } +} + +// TestCompileSkillSlotCollidingWithProjectSkillRejected checks the namespace +// trap: the server merges bindings skill slots with project-local skill slugs +// and lets the local slug win, so a same-named binding slot would be silently +// ignored. Compile must reject it and say to rename the slot. +func TestCompileSkillSlotCollidingWithProjectSkillRejected(t *testing.T) { + dir := t.TempDir() + writeFixtureProject(t, dir) + writeBindings(t, dir, `{"slots":{"skills":{"pdf-tools":{"id":"`+testSkillID+`"}}}}`) + writeSkill(t, dir, "pdf-tools") + writeAgent(t, dir, "a", `{"slug":"a","name":"A","systemPrompt":"x","skills":["pdf-tools"]}`) + initGitRepo(t, dir) + + _, issues := Compile(dir) + if !findIssue(issues, `skills slot "pdf-tools" collides with the project skill of the same slug`) { + t.Fatalf("expected a skill-namespace collision issue, got: %+v", issues) + } + if !findIssue(issues, "rename the binding slot") { + t.Fatalf("expected the issue to say to rename the slot, got: %+v", issues) + } + if issues[0].File != BindingsFileName || issues[0].Path != "/slots/skills/pdf-tools" { + t.Fatalf("issue not attributed to the colliding slot: %+v", issues[0]) + } +} + +// TestCompileSkillSlotCollisionRejectedWithoutAnyReference checks the +// collision is a namespace-level problem: it fails the compile even when no +// agent references either side. +func TestCompileSkillSlotCollisionRejectedWithoutAnyReference(t *testing.T) { + dir := t.TempDir() + writeFixtureProject(t, dir) + writeBindings(t, dir, `{"slots":{"skills":{"pdf-tools":{"id":"`+testSkillID+`"}}}}`) + writeSkill(t, dir, "pdf-tools") + initGitRepo(t, dir) + + _, issues := Compile(dir) + if !findIssue(issues, `skills slot "pdf-tools" collides`) { + t.Fatalf("expected a collision issue with no agent referencing it, got: %+v", issues) + } +} + +// TestCompileSkillSlotNotCollidingAccepted pins that a distinct slot name next +// to a project skill still compiles. +func TestCompileSkillSlotNotCollidingAccepted(t *testing.T) { + dir := t.TempDir() + writeFixtureProject(t, dir) + writeBindings(t, dir, `{"slots":{"skills":{"reporting":{"id":"`+testSkillID+`"}}}}`) + writeSkill(t, dir, "pdf-tools") + writeAgent(t, dir, "a", `{"slug":"a","name":"A","systemPrompt":"x","skills":["pdf-tools",{"slot":"reporting"}]}`) + initGitRepo(t, dir) + + _, issues := Compile(dir) + if len(issues) != 0 { + t.Fatalf("expected no issues, got: %+v", issues) + } +} + +// TestLoadStillReservedFields pins that unreserving connectors/apps/skills did +// not unreserve the rest. +func TestLoadStillReservedFields(t *testing.T) { + for _, field := range []string{"schedules", "toolPermissions", "tools", "hooks"} { + t.Run(field, func(t *testing.T) { + dir := t.TempDir() + writeFixtureProject(t, dir) + writeAgent(t, dir, "a", `{"slug":"a","name":"A","systemPrompt":"x","`+field+`":[]}`) + + _, issues := Load(dir) + if !findIssue(issues, "reserved for a future version") || !findIssue(issues, field) { + t.Fatalf("expected %q to stay reserved, got: %+v", field, issues) + } + if len(issues) != 1 { + t.Fatalf("expected exactly one issue for %q, got: %+v", field, issues) + } + }) + } +} + +// TestLoadFacetsNoLongerReserved is the mirror of the test above: the three +// unreserved fields must parse instead of erroring. +func TestLoadFacetsNoLongerReserved(t *testing.T) { + dir := t.TempDir() + writeFixtureProject(t, dir) + writeBindings(t, dir, fullBindings) + writeAgent(t, dir, "a", `{"slug":"a","name":"A","systemPrompt":"x","connectors":[{"slot":"prod-db"}],"apps":[{"slot":"crm"}],"skills":[{"slot":"reporting"}]}`) + + loaded, issues := Load(dir) + if len(issues) != 0 { + t.Fatalf("expected no issues, got: %+v", issues) + } + if len(loaded.Agents[0].Connectors) != 1 || len(loaded.Agents[0].Apps) != 1 || len(loaded.Agents[0].Skills) != 1 { + t.Fatalf("facets not parsed: %+v", loaded.Agents[0]) + } +} diff --git a/projects/compile.go b/projects/compile.go index 998837b..835a668 100644 --- a/projects/compile.go +++ b/projects/compile.go @@ -69,6 +69,9 @@ func compile(dir, skillBundlesDir string) (*CompileResult, []Issue) { Model: a.Model, SystemPrompt: prompt, Env: a.Env, + Connectors: a.Connectors, + Apps: a.Apps, + Skills: a.Skills, }) } @@ -98,6 +101,7 @@ func compile(dir, skillBundlesDir string) (*CompileResult, []Issue) { Project: CompiledProject{Name: loaded.Definition.Name}, Agents: agents, Skills: skills, + Bindings: loaded.Bindings, } configJSON, err := json.Marshal(config) diff --git a/projects/load.go b/projects/load.go index e86eb00..367a26f 100644 --- a/projects/load.go +++ b/projects/load.go @@ -20,9 +20,10 @@ var schemaMessagePrinter = message.NewPrinter(language.English) // reservedAgentFields are agent.json keys claimed by future versions. They are // rejected with a dedicated message so v1 can introduce them without silent -// behavior changes on old CLIs. +// behavior changes on old CLIs. "connectors", "apps", and "skills" were +// reserved in v0 and are now supported (MAJ-290). var reservedAgentFields = []string{ - "schedules", "connectors", "apps", "toolPermissions", "tools", "hooks", "skills", + "schedules", "toolPermissions", "tools", "hooks", } // rawSystemPrompt accepts either an inline string or {"file": "./x.md"}. @@ -50,13 +51,120 @@ func (s *rawSystemPrompt) UnmarshalJSON(b []byte) error { return nil } +// rawSkillRef accepts either a bare string (a project-local skill slug) or +// {"slot": "..."} (a platform skill via bindings.json). The two syntaxes are +// disjoint, so exactly one field is ever set. +type rawSkillRef AgentSkillRef + +func (r *rawSkillRef) UnmarshalJSON(b []byte) error { + var slug string + if err := json.Unmarshal(b, &slug); err == nil { + r.Slug = slug + return nil + } + + var obj struct { + Slot string `json:"slot"` + } + + if err := json.Unmarshal(b, &obj); err != nil { + return err + } + + r.Slot = obj.Slot + return nil +} + type rawAgent struct { - Slug string `json:"slug"` - Name string `json:"name"` - Description string `json:"description"` - Model string `json:"model"` - SystemPrompt rawSystemPrompt `json:"systemPrompt"` - Env map[string]*string `json:"env"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + Model string `json:"model"` + SystemPrompt rawSystemPrompt `json:"systemPrompt"` + Env map[string]*string `json:"env"` + Connectors []AgentConnectorRef `json:"connectors"` + Apps []AgentAppRef `json:"apps"` + Skills []rawSkillRef `json:"skills"` +} + +// checkAgentFacetDuplicates reports duplicate slots, duplicate per-connector +// tools, duplicate per-app {method,path} pairs, and duplicate skill +// references within a single agent.json. Duplicates are always an authoring +// mistake: the later entry would silently win at deploy. +func checkAgentFacetDuplicates(ra rawAgent, agentFile string) []Issue { + var issues []Issue + + seenConnectors := map[string]bool{} + for i, connector := range ra.Connectors { + if seenConnectors[connector.Slot] { + issues = append(issues, Issue{ + File: agentFile, + Path: fmt.Sprintf("/connectors/%d/slot", i), + Message: fmt.Sprintf("duplicate connector slot %q", connector.Slot), + }) + } + seenConnectors[connector.Slot] = true + + seenTools := map[string]bool{} + for j, permission := range connector.Permissions { + if seenTools[permission.Tool] { + issues = append(issues, Issue{ + File: agentFile, + Path: fmt.Sprintf("/connectors/%d/permissions/%d/tool", i, j), + Message: fmt.Sprintf("duplicate permission for tool %q on connector slot %q", permission.Tool, connector.Slot), + }) + } + seenTools[permission.Tool] = true + } + } + + seenApps := map[string]bool{} + for i, app := range ra.Apps { + if seenApps[app.Slot] { + issues = append(issues, Issue{ + File: agentFile, + Path: fmt.Sprintf("/apps/%d/slot", i), + Message: fmt.Sprintf("duplicate app slot %q", app.Slot), + }) + } + seenApps[app.Slot] = true + + seenEndpoints := map[string]bool{} + for j, permission := range app.Permissions { + key := permission.Method + " " + permission.Path + + if seenEndpoints[key] { + issues = append(issues, Issue{ + File: agentFile, + Path: fmt.Sprintf("/apps/%d/permissions/%d", i, j), + Message: fmt.Sprintf("duplicate permission for %s on app slot %q", key, app.Slot), + }) + } + seenEndpoints[key] = true + } + } + + seenSkills := map[string]bool{} + for i, skill := range ra.Skills { + key := "slug:" + skill.Slug + label := fmt.Sprintf("project skill %q", skill.Slug) + + if skill.Slot != "" { + key = "slot:" + skill.Slot + label = fmt.Sprintf("skill slot %q", skill.Slot) + } + + if seenSkills[key] { + issues = append(issues, Issue{ + File: agentFile, + Path: fmt.Sprintf("/skills/%d", i), + Message: "duplicate reference to " + label, + }) + } + seenSkills[key] = true + } + + return issues } type rawProject struct { @@ -212,6 +320,16 @@ func Load(dir string) (*LoadedProject, []Issue) { skills, skillIssues := discoverSkills(dir, srcDir) issues = append(issues, skillIssues...) + bindings, bindingIssues := loadBindings(dir) + issues = append(issues, bindingIssues...) + + skillSlugs := map[string]bool{} + for _, s := range skills { + skillSlugs[s.Slug] = true + } + + issues = append(issues, checkSkillSlotCollisions(bindings, skillSlugs)...) + agentsDir := filepath.Join(dir, srcDir, "agents") entries, err := os.ReadDir(agentsDir) if err != nil { @@ -220,7 +338,7 @@ func Load(dir string) (*LoadedProject, []Issue) { if len(issues) > 0 { return nil, issues } - return &LoadedProject{Definition: ProjectDefinition{Name: proj.Name, SrcDir: srcDir}, Skills: skills}, nil + return &LoadedProject{Definition: ProjectDefinition{Name: proj.Name, SrcDir: srcDir}, Skills: skills, Bindings: bindings}, nil } var agents []AgentDefinition @@ -267,20 +385,45 @@ func Load(dir string) (*LoadedProject, []Issue) { } seenSlugs[ra.Slug] = agentFile + if dupIssues := checkAgentFacetDuplicates(ra, agentFile); len(dupIssues) > 0 { + issues = append(issues, dupIssues...) + continue + } + systemPrompt := ra.SystemPrompt.Inline if ra.SystemPrompt.File != "" { systemPrompt = "file:" + ra.SystemPrompt.File } - agents = append(agents, AgentDefinition{ + agentSkills := make([]AgentSkillRef, 0, len(ra.Skills)) + for _, s := range ra.Skills { + agentSkills = append(agentSkills, AgentSkillRef(s)) + } + if len(agentSkills) == 0 { + agentSkills = nil + } + + agent := AgentDefinition{ Slug: ra.Slug, Name: ra.Name, Description: ra.Description, Model: ra.Model, SystemPrompt: systemPrompt, Env: ra.Env, + Connectors: ra.Connectors, + Apps: ra.Apps, + Skills: agentSkills, Dir: filepath.Join(srcDir, "agents", entry.Name()), - }) + } + + // Reference validation needs the whole project (bindings + every + // project-local skill), both of which are already resolved above. + if refIssues := validateAgentReferences(agent, bindings, skillSlugs, srcDir); len(refIssues) > 0 { + issues = append(issues, refIssues...) + continue + } + + agents = append(agents, agent) } if len(issues) > 0 { @@ -293,5 +436,6 @@ func Load(dir string) (*LoadedProject, []Issue) { Definition: ProjectDefinition{Name: proj.Name, SrcDir: srcDir}, Agents: agents, Skills: skills, + Bindings: bindings, }, nil } diff --git a/projects/load_test.go b/projects/load_test.go index 97fb98c..9c8ae06 100644 --- a/projects/load_test.go +++ b/projects/load_test.go @@ -122,10 +122,10 @@ func TestLoadDuplicateSlugs(t *testing.T) { } } -// TestLoadAgentSkillsFieldReservedAgain checks that agent-skill attachment is -// gone: agent.json's "skills" field is reserved again (load.go), not parsed -// or cross-referenced against declared skills. -func TestLoadAgentSkillsFieldReservedAgain(t *testing.T) { +// TestLoadAgentSkillsFieldSupported checks that agent.json's "skills" field is +// no longer reserved (MAJ-290): a bare string names a project-local skill and +// is cross-referenced against the project's skill directories. +func TestLoadAgentSkillsFieldSupported(t *testing.T) { dir := t.TempDir() writeFixtureProject(t, dir) @@ -139,8 +139,11 @@ func TestLoadAgentSkillsFieldReservedAgain(t *testing.T) { } _, issues := Load(dir) - if !findIssue(issues, "reserved for a future version") || !findIssue(issues, "skills") { - t.Fatalf("expected \"skills\" to be reserved again, got: %+v", issues) + if findIssue(issues, "reserved for a future version") { + t.Fatalf("\"skills\" must no longer be reserved, got: %+v", issues) + } + if !findIssue(issues, `skill "pdf-tools" is not a skill in this project`) { + t.Fatalf("expected an unresolved project-skill issue, got: %+v", issues) } } diff --git a/projects/schemas.go b/projects/schemas.go index e4de3ab..f341c77 100644 --- a/projects/schemas.go +++ b/projects/schemas.go @@ -14,7 +14,8 @@ import ( "github.com/santhosh-tekuri/jsonschema/v6" ) -// SchemaFS embeds the project.json and agent.json JSON Schemas. mono-builder +// SchemaFS embeds the project.json, agent.json, and bindings.json JSON +// Schemas. mono-builder // is the source of truth for these: it generates them from the platform's zod // definitions and serves them at GET /schemas/project.json and // GET /schemas/agent.json. The files under schemas/ are a vendored @@ -27,19 +28,21 @@ import ( var SchemaFS embed.FS var ( - schemaOnce sync.Once - projectSchema *jsonschema.Schema - agentSchema *jsonschema.Schema + schemaOnce sync.Once + projectSchema *jsonschema.Schema + agentSchema *jsonschema.Schema + bindingsSchema *jsonschema.Schema ) -// ensureSchemas compiles both embedded schemas exactly once. +// ensureSchemas compiles every embedded schema exactly once. func ensureSchemas() { schemaOnce.Do(func() { c := jsonschema.NewCompiler() for name, id := range map[string]string{ - "schemas/project.schema.json": "https://schemas.major.tech/project.json", - "schemas/agent.schema.json": "https://schemas.major.tech/agent.json", + "schemas/project.schema.json": "https://schemas.major.tech/project.json", + "schemas/agent.schema.json": "https://schemas.major.tech/agent.json", + "schemas/bindings.schema.json": "https://schemas.major.tech/bindings.json", } { raw, err := SchemaFS.ReadFile(name) if err != nil { @@ -67,6 +70,11 @@ func ensureSchemas() { if err != nil { panic("projects: agent schema failed to compile: " + err.Error()) } + + bindingsSchema, err = c.Compile("https://schemas.major.tech/bindings.json") + if err != nil { + panic("projects: bindings schema failed to compile: " + err.Error()) + } }) } @@ -81,3 +89,9 @@ func AgentSchema() *jsonschema.Schema { ensureSchemas() return agentSchema } + +// BindingsSchema returns the compiled bindings.json schema. +func BindingsSchema() *jsonschema.Schema { + ensureSchemas() + return bindingsSchema +} diff --git a/projects/schemas/SCHEMAS.sha256 b/projects/schemas/SCHEMAS.sha256 index 9597e31..f4b8e37 100644 --- a/projects/schemas/SCHEMAS.sha256 +++ b/projects/schemas/SCHEMAS.sha256 @@ -1,4 +1,5 @@ -source: /Users/josegiron/orca/workspaces/mono-builder/project-skills (working tree, apps/api/src/shared/constants/schemas) -fetched: 2026-07-25T23:08:17Z +source: /Users/josegiron/orca/workspaces/mono-builder/project-full-agent (working tree, apps/api/src/shared/constants/schemas) +fetched: 2026-07-26T07:13:33Z 47002e01681b41a1030b88e395897008b370c4a42b465b541e77038f8b2509d1 projects/schemas/project.schema.json -30f8146f88d12dcec76653f167b0843f0edb05fde6db6e74926b2f154747128b projects/schemas/agent.schema.json +92de228ebbe57c186a90c71761336b4cd69c692faf05c6f964e65b7c1b7b3a14 projects/schemas/agent.schema.json +58a425b5d3aff5e93cf8e8409301c1629e121237e8de092f962247f860d8515b projects/schemas/bindings.schema.json diff --git a/projects/schemas/agent.schema.json b/projects/schemas/agent.schema.json index d937fdc..eff8b4a 100644 --- a/projects/schemas/agent.schema.json +++ b/projects/schemas/agent.schema.json @@ -24,6 +24,67 @@ "type": "object", "propertyNames": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$" }, "additionalProperties": { "anyOf": [{ "type": "string" }, { "type": "null" }] } + }, + "connectors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "slot": { "type": "string", "minLength": 1, "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, + "permissions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tool": { "type": "string", "minLength": 1 }, + "decision": { "type": "string", "enum": ["always_allow", "ask", "always_deny"] } + }, + "required": ["tool", "decision"], + "additionalProperties": false + } + } + }, + "required": ["slot"], + "additionalProperties": false + } + }, + "apps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "slot": { "type": "string", "minLength": 1, "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, + "permissions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "method": { "type": "string", "minLength": 1 }, + "path": { "type": "string", "minLength": 1 }, + "decision": { "type": "string", "enum": ["always_allow", "ask", "always_deny"] } + }, + "required": ["method", "path", "decision"], + "additionalProperties": false + } + } + }, + "required": ["slot"], + "additionalProperties": false + } + }, + "skills": { + "type": "array", + "items": { + "anyOf": [ + { "type": "string", "minLength": 1, "maxLength": 63, "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, + { + "type": "object", + "properties": { "slot": { "type": "string", "minLength": 1, "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" } }, + "required": ["slot"], + "additionalProperties": false + } + ] + } } }, "required": ["slug", "name", "systemPrompt"], diff --git a/projects/schemas/bindings.schema.json b/projects/schemas/bindings.schema.json new file mode 100644 index 0000000..50f1f3a --- /dev/null +++ b/projects/schemas/bindings.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.major.tech/bindings.json", + "title": "Major project bindings", + "type": "object", + "properties": { + "$schema": { "type": "string" }, + "slots": { + "type": "object", + "properties": { + "resources": { + "type": "object", + "propertyNames": { "type": "string", "minLength": 1, "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { "type": "string", "minLength": 1 }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + } + }, + "required": ["type", "id"], + "additionalProperties": false + } + }, + "applications": { + "type": "object", + "propertyNames": { "type": "string", "minLength": 1, "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "skills": { + "type": "object", + "propertyNames": { "type": "string", "minLength": 1, "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + } + }, + "required": ["id"], + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/projects/schemas_test.go b/projects/schemas_test.go index 1ed0e07..61332ca 100644 --- a/projects/schemas_test.go +++ b/projects/schemas_test.go @@ -13,6 +13,7 @@ func TestSchemasCompile(t *testing.T) { }{ {"project", ProjectSchema}, {"agent", AgentSchema}, + {"bindings", BindingsSchema}, } for _, tt := range tests { diff --git a/projects/types.go b/projects/types.go index 72b792d..df1a6d8 100644 --- a/projects/types.go +++ b/projects/types.go @@ -53,9 +53,72 @@ type AgentDefinition struct { Model string SystemPrompt string Env map[string]*string + Connectors []AgentConnectorRef + Apps []AgentAppRef + Skills []AgentSkillRef Dir string } +// AgentToolPermission is one agent-scoped connector tool decision. Decision +// mirrors the platform enum (always_allow | ask | always_deny); tools not +// listed get no stored row and fall back to runtime defaults. +type AgentToolPermission struct { + Tool string `json:"tool"` + Decision string `json:"decision"` +} + +// AgentConnectorRef associates an agent with a resource binding slot, plus +// optional per-tool permissions. Slot is resolved against +// bindings.json slots.resources at compile time; the id is resolved at deploy. +type AgentConnectorRef struct { + Slot string `json:"slot"` + Permissions []AgentToolPermission `json:"permissions,omitempty"` +} + +// AgentEndpointPermission is one agent-scoped app route decision. +type AgentEndpointPermission struct { + Method string `json:"method"` + Path string `json:"path"` + Decision string `json:"decision"` +} + +// AgentAppRef associates an agent with an application binding slot (the +// agent-calls-app-routes scope), plus optional per-endpoint permissions. +type AgentAppRef struct { + Slot string `json:"slot"` + Permissions []AgentEndpointPermission `json:"permissions,omitempty"` +} + +// AgentSkillRef is one skill attachment: exactly one of Slug (a project-local +// skill compiled in the same run, authored as a bare string) or Slot (a +// platform skill via bindings.json slots.skills) is set. +type AgentSkillRef struct { + Slug string `json:"slug,omitempty"` + Slot string `json:"slot,omitempty"` +} + +// ResourceBinding is one bindings.json slots.resources entry. Type is the +// connector subtype, carried for authoring clarity and parity with the shared +// manifest shape; compile does not verify it against the platform. +type ResourceBinding struct { + Type string `json:"type"` + ID string `json:"id"` +} + +// IDBinding is one bindings.json slots.applications / slots.skills entry. +type IDBinding struct { + ID string `json:"id"` +} + +// BindingSlots is the normalized slot manifest from bindings.json. Empty +// kinds are omitted so a project that binds only resources emits only that +// key. +type BindingSlots struct { + Resources map[string]ResourceBinding `json:"resources,omitempty"` + Applications map[string]IDBinding `json:"applications,omitempty"` + Skills map[string]IDBinding `json:"skills,omitempty"` +} + // SkillDefinition is one skill directory discovered under /skills. // Load only records its slug and location; Compile reads and validates the // full bundle (skills.go). @@ -65,10 +128,12 @@ type SkillDefinition struct { } // LoadedProject is the fully parsed and validated project directory. +// Bindings is nil when the project has no bindings.json. type LoadedProject struct { Definition ProjectDefinition Agents []AgentDefinition Skills []SkillDefinition + Bindings *BindingSlots } // CompiledProject is the project block of the compiled config. @@ -78,12 +143,15 @@ type CompiledProject struct { // CompiledAgent is one agent entry of the compiled config. type CompiledAgent struct { - Slug string `json:"slug"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Model string `json:"model,omitempty"` - SystemPrompt string `json:"systemPrompt"` - Env map[string]*string `json:"env,omitempty"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Model string `json:"model,omitempty"` + SystemPrompt string `json:"systemPrompt"` + Env map[string]*string `json:"env,omitempty"` + Connectors []AgentConnectorRef `json:"connectors,omitempty"` + Apps []AgentAppRef `json:"apps,omitempty"` + Skills []AgentSkillRef `json:"skills,omitempty"` } // CompiledSkillBundle locates a skill's zipped bundle in S3. The CLI never @@ -107,11 +175,14 @@ type CompiledSkill struct { // CompiledConfig is the canonical compile output. The platform stores this // JSON on project_versions.compiled_config and deploys read it verbatim. -// Agents and Skills are optional: an empty project omits both keys rather -// than emitting "[]". +// Agents, Skills, and Bindings are optional: an empty project omits all three +// keys rather than emitting "[]" / "{}". Bindings carries the project's slot +// manifest verbatim - compile validates every agent reference against it, +// deploy resolves the slots to ids. type CompiledConfig struct { ConfigVersion int `json:"configVersion"` Project CompiledProject `json:"project"` Agents []CompiledAgent `json:"agents,omitempty"` Skills []CompiledSkill `json:"skills,omitempty"` + Bindings *BindingSlots `json:"bindings,omitempty"` } From c7300f2013c2c2a0345d0aa276840488ad9225bf Mon Sep 17 00:00:00 2001 From: josegironn <30703536+josegironn@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:09:38 -0700 Subject: [PATCH 2/2] refactor(project): resolve binding slots to uuids at compile time Compiled agents carry resourceId/applicationId/skillId inline; no bindings key in compiled output; skill slot/slug collision check removed (structurally impossible now). Authored formats and vendored schemas unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0185ReH8ZKtT8jUjUtT1ci1X Co-authored-by: Orca --- projects/bindings.go | 70 +++++++++++++++------ projects/bindings_test.go | 127 ++++++++++++++------------------------ projects/compile.go | 7 +-- projects/load.go | 2 - projects/types.go | 60 +++++++++++++----- 5 files changed, 143 insertions(+), 123 deletions(-) diff --git a/projects/bindings.go b/projects/bindings.go index 12e2c4a..26155cd 100644 --- a/projects/bindings.go +++ b/projects/bindings.go @@ -4,9 +4,10 @@ package projects // off it. bindings.json is the human-authored slot -> platform-id manifest at // the project root, next to project.json: agents reference platform resources, // applications, and skills by author-chosen slot names, and this file is where -// those slots get their concrete ids. Compile only validates that every -// reference resolves - it never bakes ids into agent configs; deploy resolves -// slots to ids server-side. +// those slots get their concrete ids. Slots are purely an authoring +// convenience: compile validates that every reference resolves and then +// substitutes the bound id inline on the referencing agent, so no slot name +// and no copy of the manifest ever reaches the compiled config. import ( "encoding/json" @@ -54,31 +55,62 @@ func loadBindings(dir string) (*BindingSlots, []Issue) { return &parsed.Slots, nil } -// checkSkillSlotCollisions rejects a bindings skills slot whose name is also a -// project-local skill slug. The server resolves the skill namespace by merging -// bindings slots with project skill slugs and letting the local slug win, so a -// collision would silently ignore the binding. Fail the compile instead and -// tell the author to rename the slot. -func checkSkillSlotCollisions(bindings *BindingSlots, skillSlugs map[string]bool) []Issue { - if bindings == nil { +// The three resolve* helpers below turn authored slot references into the +// compiled id-bearing form. They index bindings directly because Load has +// already rejected every unresolvable reference (validateAgentReferences), and +// Compile bails before reaching here if Load reported any issue - so by +// construction every slot is present. + +func resolveConnectors(refs []AgentConnectorRef, bindings *BindingSlots) []CompiledConnector { + if len(refs) == 0 { return nil } - var issues []Issue + out := make([]CompiledConnector, 0, len(refs)) + for _, ref := range refs { + out = append(out, CompiledConnector{ + ResourceID: bindings.Resources[ref.Slot].ID, + Permissions: ref.Permissions, + }) + } + + return out +} + +func resolveApps(refs []AgentAppRef, bindings *BindingSlots) []CompiledApp { + if len(refs) == 0 { + return nil + } - for _, slot := range idSlotNames(bindings.Skills) { - if !skillSlugs[slot] { + out := make([]CompiledApp, 0, len(refs)) + for _, ref := range refs { + out = append(out, CompiledApp{ + ApplicationID: bindings.Applications[ref.Slot].ID, + Permissions: ref.Permissions, + }) + } + + return out +} + +// resolveSkillRefs keeps a project-local slug as-is (the skill has no platform +// id until this same deploy publishes it) and resolves a slot to its bound id. +func resolveSkillRefs(refs []AgentSkillRef, bindings *BindingSlots) []CompiledSkillRef { + if len(refs) == 0 { + return nil + } + + out := make([]CompiledSkillRef, 0, len(refs)) + for _, ref := range refs { + if ref.Slot != "" { + out = append(out, CompiledSkillRef{SkillID: bindings.Skills[ref.Slot].ID}) continue } - issues = append(issues, Issue{ - File: BindingsFileName, - Path: "/slots/skills/" + slot, - Message: fmt.Sprintf("skills slot %q collides with the project skill of the same slug; rename the binding slot, because a project-local skill always wins over a bound platform skill", slot), - }) + out = append(out, CompiledSkillRef{Slug: ref.Slug}) } - return issues + return out } // validateAgentReferences checks that every slot an agent references exists in diff --git a/projects/bindings_test.go b/projects/bindings_test.go index 75efa58..6260a47 100644 --- a/projects/bindings_test.go +++ b/projects/bindings_test.go @@ -84,35 +84,23 @@ func TestCompileFullAgentWithBindings(t *testing.T) { t.Fatalf("expected no issues, got: %+v", issues) } - if res.Config.Bindings == nil { - t.Fatal("expected bindings in the compiled config") - } - if got := res.Config.Bindings.Resources["prod-db"]; got.Type != "postgresql" || got.ID != testResourceID { - t.Fatalf("resource binding = %+v", got) - } - if got := res.Config.Bindings.Applications["crm"].ID; got != testApplicationID { - t.Fatalf("application binding id = %q", got) - } - if got := res.Config.Bindings.Skills["reporting"].ID; got != testSkillID { - t.Fatalf("skill binding id = %q", got) - } - agent := res.Config.Agents[0] - if len(agent.Connectors) != 1 || agent.Connectors[0].Slot != "prod-db" { + if len(agent.Connectors) != 1 || agent.Connectors[0].ResourceID != testResourceID { t.Fatalf("connectors = %+v", agent.Connectors) } if p := agent.Connectors[0].Permissions; len(p) != 1 || p[0].Tool != "postgresql_psql" || p[0].Decision != "always_allow" { t.Fatalf("connector permissions = %+v", p) } - if len(agent.Apps) != 1 || agent.Apps[0].Slot != "crm" { + if len(agent.Apps) != 1 || agent.Apps[0].ApplicationID != testApplicationID { t.Fatalf("apps = %+v", agent.Apps) } if p := agent.Apps[0].Permissions; len(p) != 1 || p[0].Method != "POST" || p[0].Path != "/api/orders" || p[0].Decision != "ask" { t.Fatalf("app permissions = %+v", p) } - // A bare string normalizes to {"slug": ...}; a slot ref stays {"slot": ...}. - want := []AgentSkillRef{{Slug: "pdf-tools"}, {Slot: "reporting"}} + // A bare string stays a slug (the local skill has no id until this deploy + // publishes it); a slot resolves to the bound platform skill id. + want := []CompiledSkillRef{{Slug: "pdf-tools"}, {SkillID: testSkillID}} if len(agent.Skills) != len(want) { t.Fatalf("skills = %+v", agent.Skills) } @@ -126,50 +114,54 @@ func TestCompileFullAgentWithBindings(t *testing.T) { if err := json.Unmarshal(res.ConfigJSON, &raw); err != nil { t.Fatalf("compiled JSON does not parse: %v", err) } - if _, ok := raw["bindings"]; !ok { - t.Fatalf("expected a bindings key in the compiled JSON: %s", res.ConfigJSON) + if _, ok := raw["bindings"]; ok { + t.Fatalf("the slot manifest must not reach the compiled config: %s", res.ConfigJSON) } - if !strings.Contains(string(res.ConfigJSON), `"skills":[{"slug":"pdf-tools"},{"slot":"reporting"}]`) { - t.Fatalf("skills not normalized as expected: %s", res.ConfigJSON) + if !strings.Contains(string(res.ConfigJSON), `"skills":[{"slug":"pdf-tools"},{"skillId":"`+testSkillID+`"}]`) { + t.Fatalf("skills not resolved as expected: %s", res.ConfigJSON) + } + + // No slot name survives compilation anywhere in the output. + for _, slot := range []string{"prod-db", "crm", "reporting"} { + if strings.Contains(string(res.ConfigJSON), slot) { + t.Fatalf("slot %q leaked into the compiled config: %s", slot, res.ConfigJSON) + } } } -// TestCompileOmitsEmptyBindingKinds checks that a bindings.json declaring only -// resources emits only that kind, not empty applications/skills objects. -func TestCompileOmitsEmptyBindingKinds(t *testing.T) { +// TestCompileNeverEmitsBindingsKey checks that a project WITH bindings.json +// still emits no top-level bindings key: the manifest is compile-time input +// only, with no downstream consumer. +func TestCompileNeverEmitsBindingsKey(t *testing.T) { dir := t.TempDir() writeFixtureProject(t, dir) - writeBindings(t, dir, `{"slots":{"resources":{"prod-db":{"type":"postgresql","id":"`+testResourceID+`"}}}}`) + writeBindings(t, dir, fullBindings) res, issues := Compile(dir) if len(issues) != 0 { t.Fatalf("expected no issues, got: %+v", issues) } - if !strings.Contains(string(res.ConfigJSON), `"bindings":{"resources":`) { - t.Fatalf("expected a resources-only bindings block: %s", res.ConfigJSON) + if strings.Contains(string(res.ConfigJSON), `"bindings"`) { + t.Fatalf("expected no bindings key: %s", res.ConfigJSON) } - if strings.Contains(string(res.ConfigJSON), `"applications"`) || strings.Contains(string(res.ConfigJSON), `"skills"`) { - t.Fatalf("expected empty binding kinds to be omitted: %s", res.ConfigJSON) + for _, id := range []string{testResourceID, testApplicationID, testSkillID} { + if strings.Contains(string(res.ConfigJSON), id) { + t.Fatalf("unreferenced binding id %q leaked into the compiled config: %s", id, res.ConfigJSON) + } } } // TestCompileWithoutBindingsFileOmitsBindingsKey pins backward compatibility: // a project that predates bindings.json compiles to exactly the bytes it did -// before, with no bindings key. +// before, with no bindings key and no facet keys. func TestCompileWithoutBindingsFileOmitsBindingsKey(t *testing.T) { res, issues := Compile("testdata/valid") if len(issues) != 0 { t.Fatalf("expected no issues, got: %+v", issues) } - if res.Config.Bindings != nil { - t.Fatalf("expected nil bindings, got: %+v", res.Config.Bindings) - } - if strings.Contains(string(res.ConfigJSON), `"bindings"`) { - t.Fatalf("expected no bindings key in compiled JSON, got: %s", res.ConfigJSON) - } - for _, key := range []string{`"connectors"`, `"apps"`, `"skills"`} { + for _, key := range []string{`"bindings"`, `"connectors"`, `"apps"`, `"skills"`} { if strings.Contains(string(res.ConfigJSON), key) { t.Fatalf("expected no %s key for an old project, got: %s", key, res.ConfigJSON) } @@ -377,59 +369,32 @@ func TestCompileInvalidAgentFacetShapeRejected(t *testing.T) { } } -// TestCompileSkillSlotCollidingWithProjectSkillRejected checks the namespace -// trap: the server merges bindings skill slots with project-local skill slugs -// and lets the local slug win, so a same-named binding slot would be silently -// ignored. Compile must reject it and say to rename the slot. -func TestCompileSkillSlotCollidingWithProjectSkillRejected(t *testing.T) { +// TestCompileSkillSlotMayShareNameWithProjectSkill pins that a skills slot +// named after a project-local skill is NOT a conflict: compile resolves the +// slot to {"skillId"} and the local slug to {"slug"}, two structurally +// distinct shapes, so there is no merged namespace for them to collide in. +func TestCompileSkillSlotMayShareNameWithProjectSkill(t *testing.T) { dir := t.TempDir() writeFixtureProject(t, dir) writeBindings(t, dir, `{"slots":{"skills":{"pdf-tools":{"id":"`+testSkillID+`"}}}}`) writeSkill(t, dir, "pdf-tools") - writeAgent(t, dir, "a", `{"slug":"a","name":"A","systemPrompt":"x","skills":["pdf-tools"]}`) + writeAgent(t, dir, "a", `{"slug":"a","name":"A","systemPrompt":"x","skills":["pdf-tools",{"slot":"pdf-tools"}]}`) initGitRepo(t, dir) - _, issues := Compile(dir) - if !findIssue(issues, `skills slot "pdf-tools" collides with the project skill of the same slug`) { - t.Fatalf("expected a skill-namespace collision issue, got: %+v", issues) - } - if !findIssue(issues, "rename the binding slot") { - t.Fatalf("expected the issue to say to rename the slot, got: %+v", issues) - } - if issues[0].File != BindingsFileName || issues[0].Path != "/slots/skills/pdf-tools" { - t.Fatalf("issue not attributed to the colliding slot: %+v", issues[0]) + res, issues := Compile(dir) + if len(issues) != 0 { + t.Fatalf("expected no issues, got: %+v", issues) } -} - -// TestCompileSkillSlotCollisionRejectedWithoutAnyReference checks the -// collision is a namespace-level problem: it fails the compile even when no -// agent references either side. -func TestCompileSkillSlotCollisionRejectedWithoutAnyReference(t *testing.T) { - dir := t.TempDir() - writeFixtureProject(t, dir) - writeBindings(t, dir, `{"slots":{"skills":{"pdf-tools":{"id":"`+testSkillID+`"}}}}`) - writeSkill(t, dir, "pdf-tools") - initGitRepo(t, dir) - _, issues := Compile(dir) - if !findIssue(issues, `skills slot "pdf-tools" collides`) { - t.Fatalf("expected a collision issue with no agent referencing it, got: %+v", issues) + want := []CompiledSkillRef{{Slug: "pdf-tools"}, {SkillID: testSkillID}} + got := res.Config.Agents[0].Skills + if len(got) != len(want) { + t.Fatalf("skills = %+v", got) } -} - -// TestCompileSkillSlotNotCollidingAccepted pins that a distinct slot name next -// to a project skill still compiles. -func TestCompileSkillSlotNotCollidingAccepted(t *testing.T) { - dir := t.TempDir() - writeFixtureProject(t, dir) - writeBindings(t, dir, `{"slots":{"skills":{"reporting":{"id":"`+testSkillID+`"}}}}`) - writeSkill(t, dir, "pdf-tools") - writeAgent(t, dir, "a", `{"slug":"a","name":"A","systemPrompt":"x","skills":["pdf-tools",{"slot":"reporting"}]}`) - initGitRepo(t, dir) - - _, issues := Compile(dir) - if len(issues) != 0 { - t.Fatalf("expected no issues, got: %+v", issues) + for i := range want { + if got[i] != want[i] { + t.Fatalf("skills[%d] = %+v, want %+v", i, got[i], want[i]) + } } } diff --git a/projects/compile.go b/projects/compile.go index 835a668..c0c5263 100644 --- a/projects/compile.go +++ b/projects/compile.go @@ -69,9 +69,9 @@ func compile(dir, skillBundlesDir string) (*CompileResult, []Issue) { Model: a.Model, SystemPrompt: prompt, Env: a.Env, - Connectors: a.Connectors, - Apps: a.Apps, - Skills: a.Skills, + Connectors: resolveConnectors(a.Connectors, loaded.Bindings), + Apps: resolveApps(a.Apps, loaded.Bindings), + Skills: resolveSkillRefs(a.Skills, loaded.Bindings), }) } @@ -101,7 +101,6 @@ func compile(dir, skillBundlesDir string) (*CompileResult, []Issue) { Project: CompiledProject{Name: loaded.Definition.Name}, Agents: agents, Skills: skills, - Bindings: loaded.Bindings, } configJSON, err := json.Marshal(config) diff --git a/projects/load.go b/projects/load.go index 367a26f..e9efbff 100644 --- a/projects/load.go +++ b/projects/load.go @@ -328,8 +328,6 @@ func Load(dir string) (*LoadedProject, []Issue) { skillSlugs[s.Slug] = true } - issues = append(issues, checkSkillSlotCollisions(bindings, skillSlugs)...) - agentsDir := filepath.Join(dir, srcDir, "agents") entries, err := os.ReadDir(agentsDir) if err != nil { diff --git a/projects/types.go b/projects/types.go index df1a6d8..3299de5 100644 --- a/projects/types.go +++ b/projects/types.go @@ -67,14 +67,22 @@ type AgentToolPermission struct { Decision string `json:"decision"` } -// AgentConnectorRef associates an agent with a resource binding slot, plus -// optional per-tool permissions. Slot is resolved against -// bindings.json slots.resources at compile time; the id is resolved at deploy. +// AgentConnectorRef is one authored agent.json connectors[] entry: a resource +// binding slot plus optional per-tool permissions. Compile resolves Slot +// against bindings.json slots.resources into a CompiledConnector. type AgentConnectorRef struct { Slot string `json:"slot"` Permissions []AgentToolPermission `json:"permissions,omitempty"` } +// CompiledConnector is one connector association in the compiled config, with +// its binding slot already resolved to the bound resource id. Slots never +// reach the platform: compile owns resolution, deploy consumes ids. +type CompiledConnector struct { + ResourceID string `json:"resourceId"` + Permissions []AgentToolPermission `json:"permissions,omitempty"` +} + // AgentEndpointPermission is one agent-scoped app route decision. type AgentEndpointPermission struct { Method string `json:"method"` @@ -82,21 +90,39 @@ type AgentEndpointPermission struct { Decision string `json:"decision"` } -// AgentAppRef associates an agent with an application binding slot (the -// agent-calls-app-routes scope), plus optional per-endpoint permissions. +// AgentAppRef is one authored agent.json apps[] entry: an application binding +// slot (the agent-calls-app-routes scope) plus optional per-endpoint +// permissions. type AgentAppRef struct { Slot string `json:"slot"` Permissions []AgentEndpointPermission `json:"permissions,omitempty"` } -// AgentSkillRef is one skill attachment: exactly one of Slug (a project-local -// skill compiled in the same run, authored as a bare string) or Slot (a -// platform skill via bindings.json slots.skills) is set. +// CompiledApp is one app association in the compiled config, with its binding +// slot already resolved to the bound application id. +type CompiledApp struct { + ApplicationID string `json:"applicationId"` + Permissions []AgentEndpointPermission `json:"permissions,omitempty"` +} + +// AgentSkillRef is one authored skill attachment: exactly one of Slug (a +// project-local skill compiled in the same run, authored as a bare string) or +// Slot (a platform skill via bindings.json slots.skills) is set. type AgentSkillRef struct { Slug string `json:"slug,omitempty"` Slot string `json:"slot,omitempty"` } +// CompiledSkillRef is one skill attachment in the compiled config: exactly one +// of Slug (a project-local skill published by this same deploy, which has no +// id until then) or SkillID (a platform skill, resolved from its binding slot +// at compile time) is set. The two are structurally distinct, so there is no +// shared namespace a slot name could collide in. +type CompiledSkillRef struct { + Slug string `json:"slug,omitempty"` + SkillID string `json:"skillId,omitempty"` +} + // ResourceBinding is one bindings.json slots.resources entry. Type is the // connector subtype, carried for authoring clarity and parity with the shared // manifest shape; compile does not verify it against the platform. @@ -141,7 +167,8 @@ type CompiledProject struct { Name string `json:"name"` } -// CompiledAgent is one agent entry of the compiled config. +// CompiledAgent is one agent entry of the compiled config. Every binding slot +// the author wrote is already resolved to a platform id here. type CompiledAgent struct { Slug string `json:"slug"` Name string `json:"name"` @@ -149,9 +176,9 @@ type CompiledAgent struct { Model string `json:"model,omitempty"` SystemPrompt string `json:"systemPrompt"` Env map[string]*string `json:"env,omitempty"` - Connectors []AgentConnectorRef `json:"connectors,omitempty"` - Apps []AgentAppRef `json:"apps,omitempty"` - Skills []AgentSkillRef `json:"skills,omitempty"` + Connectors []CompiledConnector `json:"connectors,omitempty"` + Apps []CompiledApp `json:"apps,omitempty"` + Skills []CompiledSkillRef `json:"skills,omitempty"` } // CompiledSkillBundle locates a skill's zipped bundle in S3. The CLI never @@ -175,14 +202,13 @@ type CompiledSkill struct { // CompiledConfig is the canonical compile output. The platform stores this // JSON on project_versions.compiled_config and deploys read it verbatim. -// Agents, Skills, and Bindings are optional: an empty project omits all three -// keys rather than emitting "[]" / "{}". Bindings carries the project's slot -// manifest verbatim - compile validates every agent reference against it, -// deploy resolves the slots to ids. +// Agents and Skills are optional: an empty project omits both keys rather +// than emitting "[]". bindings.json itself is NOT carried here - compile +// resolves every slot to a platform id inline on the agents that reference +// them, so the slot manifest has no consumer downstream. type CompiledConfig struct { ConfigVersion int `json:"configVersion"` Project CompiledProject `json:"project"` Agents []CompiledAgent `json:"agents,omitempty"` Skills []CompiledSkill `json:"skills,omitempty"` - Bindings *BindingSlots `json:"bindings,omitempty"` }