Ver código fonte

feat(schema): add oac: block and order-preserving permission rules

Adds the canonical `oac:` frontmatter block (id, name, category, type,
version, author, tags, dependencies, targets) so one file can fully define
one component. This is what dissolves .opencode/config/agent-metadata.json:
the sidecar exists only because OpenCode rejects unknown frontmatter fields,
and a build step that strips the block removes that constraint.

Replaces GranularPermissionSchema's z.record with an ordered array of
{pattern, action} rules. A record is an unordered map, so last-match-wins
precedence had no schema-level guarantee — it held only by accident of JS
string-key insertion order. Capability entries are ordered too, since
OpenCode flattens the capability map before resolving and a "*" key competes
positionally with specific ones. Authored map sugar is still accepted and
desugars into the ordered form; the on-disk OpenCode format is unchanged.

The shape mirrors OpenCode's live runtime rules, which the precedence
experiment captured verbatim and whose resolver is findLast().

Category accepts the real corpus rather than the documented enum: 21 of 28
sidecar entries use values the enum rejects. Per the ratified rule that the
schema must accept its own corpus, the root vocabulary stays closed but an
optional /sub-segment is allowed, so typos still fail.

targets admits any platform with a working adapter; which of them oac build
wires up is a separate concern. Empty targets is rejected rather than
treated as universal — a component that emits nowhere is dead weight the
build would silently skip.

Bumps lib to ES2023 for findLast; target and emit are unchanged.

44 new tests; 628 pass, build clean.
darrenhinde 2 semanas atrás
pai
commit
319bec0931

+ 3 - 3
packages/compatibility-layer/src/core/TranslationEngine.ts

@@ -17,7 +17,7 @@ import type {
   OpenAgent,
   AgentFrontmatter,
   ToolAccess,
-  GranularPermission,
+  PermissionMap,
   ContextReference,
   SkillReference,
 } from "../types.js";
@@ -282,13 +282,13 @@ export class TranslationEngine {
     }
 
     // Translate permissions
-    let oacPermissions: GranularPermission | undefined;
+    let oacPermissions: PermissionMap | undefined;
     if (source.permissions) {
       const permResult = mapPermissionsToOAC(
         source.permissions,
         platform
       );
-      oacPermissions = permResult.permissions as GranularPermission;
+      oacPermissions = permResult.permissions as PermissionMap;
       warnings.push(...permResult.warnings);
     }
 

+ 7 - 7
packages/compatibility-layer/src/mappers/PermissionMapper.ts

@@ -13,7 +13,7 @@
  * ```
  */
 
-import type { GranularPermission, PermissionRule } from "../types.js";
+import type { PermissionMap, PermissionRule } from "../types.js";
 
 // ============================================================================
 // Types
@@ -38,7 +38,7 @@ export interface BinaryPermissions {
  * Result of permission mapping
  */
 export interface PermissionMappingResult {
-  permissions: BinaryPermissions | GranularPermission;
+  permissions: BinaryPermissions | PermissionMap;
   warnings: string[];
 }
 
@@ -135,7 +135,7 @@ export function isGranularRule(rule: PermissionRule): boolean {
  * @returns Binary permissions with warnings
  */
 export function mapPermissionsFromOAC(
-  permissions: GranularPermission,
+  permissions: PermissionMap,
   platform: Exclude<PermissionPlatform, "oac">,
   strategy: DegradationStrategy = "permissive"
 ): PermissionMappingResult {
@@ -179,7 +179,7 @@ export function mapPermissionsToOAC(
   permissions: BinaryPermissions,
   _platform: Exclude<PermissionPlatform, "oac">
 ): PermissionMappingResult {
-  const result: GranularPermission = {};
+  const result: PermissionMap = {};
 
   for (const [tool, enabled] of Object.entries(permissions)) {
     if (enabled !== undefined) {
@@ -302,7 +302,7 @@ export function mergePermissionRules(...rules: PermissionRule[]): PermissionRule
  * @param permissions - OAC permissions to check
  * @returns True if any granular rules exist
  */
-export function hasGranularPermissions(permissions: GranularPermission): boolean {
+export function hasGranularPermissions(permissions: PermissionMap): boolean {
   return Object.values(permissions).some(isGranularRule);
 }
 
@@ -312,7 +312,7 @@ export function hasGranularPermissions(permissions: GranularPermission): boolean
  * @param permissions - OAC permissions to check
  * @returns True if any 'ask' rules exist
  */
-export function hasAskPermissions(permissions: GranularPermission): boolean {
+export function hasAskPermissions(permissions: PermissionMap): boolean {
   const checkRule = (rule: PermissionRule): boolean => {
     if (rule === "ask") return true;
     if (typeof rule === "object" && rule !== null) {
@@ -332,7 +332,7 @@ export function hasAskPermissions(permissions: GranularPermission): boolean {
  * @returns Array of warning messages
  */
 export function analyzePermissionDegradation(
-  permissions: GranularPermission,
+  permissions: PermissionMap,
   platform: Exclude<PermissionPlatform, "oac">
 ): string[] {
   const warnings: string[] = [];

+ 304 - 9
packages/compatibility-layer/src/types.ts

@@ -24,29 +24,164 @@ export const ToolAccessSchema = z.object({
 // Permission Schemas
 // ============================================================================
 
+/**
+ * A single permission decision.
+ *
+ * Field name rationale: `action` mirrors OpenCode's own runtime rule shape
+ * (`{ permission, pattern, action }`), verified against the installed resolver in
+ * `docs/architecture/canonical-refactor/10-PRECEDENCE-EXPERIMENT.md` §7.
+ */
+export const PermissionActionSchema = z.enum(["allow", "deny", "ask"]);
+
 /**
  * Permission rules can be:
  * - A literal: "allow", "deny", "ask"
  * - A boolean (true = allow, false = deny)
  * - A record mapping specific operations to permission literals
+ *
+ * This is the *authored* (on-disk YAML) form. It is sugar over the canonical
+ * ordered form below — see {@link desugarPermission}.
  */
 export const PermissionRuleSchema = z.union([
   z.literal("allow"),
   z.literal("deny"),
   z.literal("ask"),
   z.boolean(),
-  z.record(z.string(), z.union([
-    z.literal("allow"),
-    z.literal("deny"),
-    z.literal("ask"),
-  ])),
+  z.record(z.string(), PermissionActionSchema),
 ]);
 
 /**
- * Granular permissions allow fine-grained control over different operations.
- * Maps operation names to permission rules.
+ * The legacy/authored `permission:` mapping exactly as OpenCode accepts it on disk:
+ * capability name -> rule. This is what {@link AgentFrontmatterSchema} still carries,
+ * so existing agent files keep parsing unchanged.
+ *
+ * ⚠️ A JS object is an UNORDERED map as far as any schema is concerned. This shape is
+ * accepted as INPUT only; the canonical representation is {@link GranularPermissionSchema}.
+ */
+export const PermissionMapSchema = z.record(z.string(), PermissionRuleSchema);
+
+/**
+ * One ordered rule within a capability. `pattern` is a glob whose namespace depends on
+ * the capability (path glob for read/write/edit, command glob for bash, agent id for task).
+ */
+export const PermissionRuleEntrySchema = z
+  .object({
+    pattern: z.string().min(1),
+    action: PermissionActionSchema,
+  })
+  .strict();
+
+/**
+ * Ordered rules for a single capability. **Array order is semantic**: rules are evaluated
+ * in authored order and the LAST matching rule wins.
+ *
+ * Duplicate patterns are representable here by design — the OpenCode serializer, not the
+ * schema, is responsible for refusing to emit them (the map format cannot round-trip them).
+ */
+export const PermissionRuleListSchema = z.array(PermissionRuleEntrySchema);
+
+/**
+ * One capability's ordered rule list. Capability entries are themselves ordered, because
+ * OpenCode flattens the capability map into a single rule list before resolving and
+ * wildcard capability keys (e.g. `"*"`) can match alongside specific ones.
  */
-export const GranularPermissionSchema = z.record(z.string(), PermissionRuleSchema);
+export const GranularPermissionEntrySchema = z
+  .object({
+    capability: z.string().min(1),
+    rules: PermissionRuleListSchema,
+  })
+  .strict();
+
+/**
+ * Granular permissions in their canonical, ORDER-PRESERVING representation.
+ *
+ * This deliberately replaces the previous `z.record(...)` map. Last-match-wins precedence
+ * is meaningless without a guaranteed order, and a record only preserved order by accident
+ * of ECMAScript string-key insertion ordering — an accident that demonstrably breaks for
+ * integer-like keys (see {@link desugarPermission}).
+ *
+ * Semantics (confirmed live against OpenCode 1.17.20 —
+ * `docs/architecture/canonical-refactor/10-PRECEDENCE-EXPERIMENT.md`):
+ * flatten entries in order, then resolve with **last-match-wins** (`Array.findLast`).
+ */
+export const GranularPermissionSchema = z.array(GranularPermissionEntrySchema);
+
+/** Scopes that ECMAScript would silently reorder to the front of an object's key list. */
+const INTEGER_LIKE_SCOPE = /^\d+$/;
+
+/**
+ * What an author may write under `permission:` — either the canonical ordered form or the
+ * OpenCode map sugar. Desugars to the canonical ordered form, preserving source order.
+ */
+export const PermissionInputSchema = z
+  .union([GranularPermissionSchema, PermissionMapSchema])
+  .transform((input, ctx) => desugar(input, ctx));
+
+// ----------------------------------------------------------------------------
+// Permission desugaring
+// ----------------------------------------------------------------------------
+
+type PermissionRuleInput = z.infer<typeof PermissionRuleSchema>;
+type PermissionRuleEntryOut = z.infer<typeof PermissionRuleEntrySchema>;
+type GranularPermissionOut = z.infer<typeof GranularPermissionSchema>;
+
+/** Reject scopes ECMAScript key ordering would silently move, invalidating rule order. */
+function reject(scope: string, ctx: z.RefinementCtx, path: (string | number)[]): boolean {
+  if (!INTEGER_LIKE_SCOPE.test(scope.trim())) return false;
+  ctx.addIssue({
+    code: z.ZodIssueCode.custom,
+    path,
+    message:
+      `integer-like scope "${scope}" is not allowed: ECMAScript reorders integer-like ` +
+      `object keys to the front, which silently changes last-match-wins precedence`,
+  });
+  return true;
+}
+
+/** Expand one authored rule (scalar, boolean or scope map) into ordered rule entries. */
+function expand(
+  rule: PermissionRuleInput,
+  ctx: z.RefinementCtx,
+  path: (string | number)[]
+): PermissionRuleEntryOut[] {
+  if (typeof rule === "boolean") {
+    return [{ pattern: "*", action: rule ? "allow" : "deny" }];
+  }
+  if (typeof rule === "string") {
+    return [{ pattern: "*", action: rule }];
+  }
+  return Object.entries(rule).flatMap(([pattern, action]) =>
+    reject(pattern, ctx, [...path, pattern]) ? [] : [{ pattern, action }]
+  );
+}
+
+/**
+ * Back-compat parser: converts authored `permission:` input into the canonical ordered
+ * form **in source order**.
+ *
+ * - `edit: deny`                     -> [{ capability: "edit", rules: [{ pattern: "*", action: "deny" }] }]
+ * - `bash: { "*": deny, "ls*": allow }` -> rules in exactly that order (the later rule wins)
+ * - already-ordered input            -> identity
+ */
+function desugar(
+  input: GranularPermissionOut | Record<string, PermissionRuleInput>,
+  ctx: z.RefinementCtx
+): GranularPermissionOut {
+  if (Array.isArray(input)) return input;
+  return Object.entries(input).flatMap(([capability, rule]) =>
+    reject(capability, ctx, [capability])
+      ? []
+      : [{ capability, rules: expand(rule, ctx, [capability]) }]
+  );
+}
+
+/**
+ * Desugar authored permission input into the canonical ordered form.
+ * Throws a `ZodError` on integer-like scopes or malformed input.
+ */
+export function desugarPermission(input: unknown): GranularPermissionOut {
+  return PermissionInputSchema.parse(input);
+}
 
 // ============================================================================
 // Context Schemas
@@ -185,11 +320,155 @@ export const AgentFrontmatterSchema = z.object({
   hidden: z.boolean().optional(),
   prompt: z.string().optional(),
   tools: ToolAccessSchema.optional(),
-  permission: GranularPermissionSchema.optional(),
+  permission: PermissionMapSchema.optional(),
   skills: z.array(SkillReferenceSchema).optional(),
   hooks: z.array(HookDefinitionSchema).optional(),
 });
 
+// ============================================================================
+// Canonical `oac:` Frontmatter Block
+// ============================================================================
+
+/**
+ * Stable machine identity. Kebab-case slug — verified against all 28 entries in
+ * `.opencode/config/agent-metadata.json`.
+ */
+export const OacIdSchema = z
+  .string()
+  .regex(
+    /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
+    "id must be kebab-case: lowercase alphanumeric words joined by single hyphens"
+  );
+
+/** SemVer of the authored component. Corpus uses 1.0.0 / 2.0.0. */
+export const OacVersionSchema = z
+  .string()
+  .regex(/^\d+\.\d+\.\d+$/, "version must be SemVer (MAJOR.MINOR.PATCH)");
+
+const CATEGORY_ROOTS: readonly string[] = [
+  ...AgentCategorySchema.options,
+  // Present in the real corpus but absent from AgentCategorySchema:
+  "subagents", // 20 entries: subagents/{code,core,development,system-builder,test,utils}
+  "testing", // 1 entry: eval-runner
+];
+
+const CATEGORY_SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
+
+/**
+ * Organizational category. Mirrors the agent's directory under `.opencode/agent/`:
+ * a closed root vocabulary, optionally followed by one `/`-joined sub-segment.
+ *
+ * ⚠️ Deliberately a superset of {@link AgentCategorySchema}, which cannot express the
+ * corpus: 21 of the 28 entries in `.opencode/config/agent-metadata.json` use values that
+ * enum rejects (`subagents/core`, `subagents/code`, …, `testing`). The ratified
+ * "the schema must accept its own corpus" rule (02-canonical-schema.md v3) wins over the
+ * brief's "reuses AgentCategorySchema". The root stays closed so typos are still caught.
+ */
+export const OacCategorySchema = z.string().refine(
+  (value) => {
+    const segments = value.split("/");
+    if (segments.length > 2) return false;
+    const [root = "", sub] = segments;
+    if (!CATEGORY_ROOTS.includes(root)) return false;
+    return sub === undefined || CATEGORY_SEGMENT.test(sub);
+  },
+  {
+    message:
+      `category must be "<root>" or "<root>/<segment>" where root is one of: ` +
+      CATEGORY_ROOTS.join(", "),
+  }
+);
+
+/**
+ * Platforms a component can declare as an emit target. Every value here has a
+ * working adapter; which of them `oac build` actually wires up is a separate,
+ * narrower question — see the build command's target registry.
+ */
+export const BuildTargetSchema = z.enum([
+  "opencode",
+  "claude-code",
+  "cursor",
+  "windsurf",
+]);
+
+/**
+ * Which platforms this component is emitted to. At least one target is required;
+ * `targets: []` is rejected because a component that emits nowhere is dead weight the
+ * build would silently skip. Defaults to `["opencode"]` — true of all 34 agents on disk —
+ * so an `agent-metadata.json` entry validates as an `oac:` block verbatim.
+ */
+export const BuildTargetsSchema = z
+  .array(BuildTargetSchema)
+  .min(1, "targets must list at least one build target")
+  .default(["opencode"]);
+
+/**
+ * A dependency reference in either authored form:
+ * - the flat typed string the corpus uses today (`"subagent:tester"`, `"context:standards-code"`)
+ * - the structured {@link DependencyReferenceSchema} form
+ *
+ * Both normalize to `{ type, id }`, so `.opencode/config/agent-metadata.json` round-trips
+ * byte-for-byte with zero migration.
+ */
+export const DependencyRefInputSchema = z.union([
+  z.string().transform((value, ctx): z.infer<typeof DependencyReferenceSchema> => {
+    const separator = value.indexOf(":");
+    const parsed = DependencyReferenceSchema.safeParse({
+      type: value.slice(0, separator),
+      id: value.slice(separator + 1),
+    });
+    if (separator <= 0 || !parsed.success) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        message:
+          `dependency "${value}" must be "<type>:<id>" where type is one of: ` +
+          DependencyReferenceSchema.shape.type.options.join(", "),
+      });
+      return z.NEVER;
+    }
+    return parsed.data;
+  }),
+  DependencyReferenceSchema,
+]);
+
+/**
+ * The canonical `oac:` frontmatter block — everything a component needs that OpenCode's
+ * frontmatter schema rejects as an unknown field. This is precisely the content of
+ * `.opencode/config/agent-metadata.json`; carrying it here is what lets that sidecar be
+ * dissolved. `oac build` strips this block when emitting OpenCode agent files.
+ *
+ * Strict: an unknown key is an error, never silently dropped.
+ */
+export const OacBlockSchema = z
+  .object({
+    id: OacIdSchema,
+    name: z.string().min(1),
+    category: OacCategorySchema,
+    type: AgentTypeSchema,
+    version: OacVersionSchema.default("1.0.0"),
+    author: z.string().min(1).default("opencode"),
+    tags: z.array(z.string()).default([]),
+    dependencies: z.array(DependencyRefInputSchema).default([]),
+    targets: BuildTargetsSchema,
+  })
+  .strict();
+
+// ============================================================================
+// Canonical Agent Schema
+// ============================================================================
+
+/**
+ * A canonical agent file: OpenCode-legal frontmatter PLUS the `oac:` block. One file
+ * fully defines one component — no sidecar, no second source of truth.
+ *
+ * `permission` accepts the authored OpenCode map sugar and desugars it into the ordered
+ * {@link GranularPermissionSchema} form, in source order.
+ */
+export const CanonicalAgentSchema = AgentFrontmatterSchema.extend({
+  oac: OacBlockSchema,
+  permission: PermissionInputSchema.optional(),
+});
+
 // ============================================================================
 // Agent Metadata Schema
 // ============================================================================
@@ -261,8 +540,17 @@ export const ToolConfigSchema = z.object({
 // ============================================================================
 
 export type ToolAccess = z.infer<typeof ToolAccessSchema>;
+export type PermissionAction = z.infer<typeof PermissionActionSchema>;
 export type PermissionRule = z.infer<typeof PermissionRuleSchema>;
+/** The authored (OpenCode on-disk) permission map. Unordered — input only. */
+export type PermissionMap = z.infer<typeof PermissionMapSchema>;
+export type PermissionRuleEntry = z.infer<typeof PermissionRuleEntrySchema>;
+export type PermissionRuleList = z.infer<typeof PermissionRuleListSchema>;
+export type GranularPermissionEntry = z.infer<typeof GranularPermissionEntrySchema>;
+/** Canonical ORDERED granular permissions. Array order is semantic (last-match-wins). */
 export type GranularPermission = z.infer<typeof GranularPermissionSchema>;
+/** Authored permission input, before desugaring. */
+export type PermissionInput = z.input<typeof PermissionInputSchema>;
 export type ContextPriority = z.infer<typeof ContextPrioritySchema>;
 export type ContextReference = z.infer<typeof ContextReferenceSchema>;
 export type DependencyReference = z.infer<typeof DependencyReferenceSchema>;
@@ -275,6 +563,13 @@ export type SkillReference = z.infer<typeof SkillReferenceSchema>;
 export type HookEvent = z.infer<typeof HookEventSchema>;
 export type HookDefinition = z.infer<typeof HookDefinitionSchema>;
 export type AgentFrontmatter = z.infer<typeof AgentFrontmatterSchema>;
+export type OacId = z.infer<typeof OacIdSchema>;
+export type OacCategory = z.infer<typeof OacCategorySchema>;
+export type BuildTarget = z.infer<typeof BuildTargetSchema>;
+export type OacBlock = z.infer<typeof OacBlockSchema>;
+/** Authored `oac:` block, before defaults are applied. */
+export type OacBlockInput = z.input<typeof OacBlockSchema>;
+export type CanonicalAgent = z.infer<typeof CanonicalAgentSchema>;
 export type AgentMetadata = z.infer<typeof AgentMetadataSchema>;
 export type OpenAgent = z.infer<typeof OpenAgentSchema>;
 export type ToolConfig = z.infer<typeof ToolConfigSchema>;

+ 279 - 0
packages/compatibility-layer/tests/unit/types/OacBlock.test.ts

@@ -0,0 +1,279 @@
+/**
+ * Unit tests for the canonical `oac:` frontmatter block.
+ *
+ * The block carries what `.opencode/config/agent-metadata.json` holds today. The sidecar
+ * exists only because OpenCode rejects unknown frontmatter keys; `oac build` strips the
+ * block on emit, which is what lets the sidecar be dissolved.
+ *
+ * The load-bearing test here is `accepts every entry in the real corpus` — a schema that
+ * does not accept its own corpus is not a schema, it is a wish.
+ */
+
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+  OacBlockSchema,
+  CanonicalAgentSchema,
+  OacCategorySchema,
+  BuildTargetsSchema,
+} from "../../../src/types.js";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = join(HERE, "../../../../..");
+const METADATA_PATH = join(REPO_ROOT, ".opencode/config/agent-metadata.json");
+
+interface MetadataEntry {
+  id: string;
+  name: string;
+  category: string;
+  type: string;
+  version: string;
+  author: string;
+  tags?: string[];
+  dependencies?: string[];
+}
+
+function corpus(): Record<string, MetadataEntry> {
+  const raw = JSON.parse(readFileSync(METADATA_PATH, "utf-8")) as {
+    agents: Record<string, MetadataEntry>;
+  };
+  return raw.agents;
+}
+
+const VALID_BLOCK = {
+  id: "openagent",
+  name: "OpenAgent",
+  category: "core",
+  type: "agent",
+  version: "1.0.0",
+  author: "opencode",
+  tags: ["universal", "coordination"],
+  dependencies: ["subagent:contextscout", "context:standards-code"],
+  targets: ["opencode", "claude-code"],
+};
+
+describe("OacBlockSchema", () => {
+  describe("valid blocks", () => {
+    it("accepts a fully populated block", () => {
+      const result = OacBlockSchema.parse(VALID_BLOCK);
+
+      expect(result.id).toBe("openagent");
+      expect(result.targets).toEqual(["opencode", "claude-code"]);
+    });
+
+    it("normalizes flat typed dependency strings to { type, id }", () => {
+      const result = OacBlockSchema.parse(VALID_BLOCK);
+
+      expect(result.dependencies).toEqual([
+        { type: "subagent", id: "contextscout" },
+        { type: "context", id: "standards-code" },
+      ]);
+    });
+
+    it("accepts structured dependency objects alongside the string form", () => {
+      const result = OacBlockSchema.parse({
+        ...VALID_BLOCK,
+        dependencies: [{ type: "skill", id: "task-management" }, "context:standards-docs"],
+      });
+
+      expect(result.dependencies).toEqual([
+        { type: "skill", id: "task-management" },
+        { type: "context", id: "standards-docs" },
+      ]);
+    });
+
+    it("accepts path-style dependency ids (registry uses them)", () => {
+      const result = OacBlockSchema.parse({
+        ...VALID_BLOCK,
+        dependencies: ["context:core/standards/code-quality"],
+      });
+
+      expect(result.dependencies).toEqual([
+        { type: "context", id: "core/standards/code-quality" },
+      ]);
+    });
+
+    it("defaults version, author, tags, dependencies and targets", () => {
+      const result = OacBlockSchema.parse({
+        id: "contextscout",
+        name: "ContextScout",
+        category: "subagents/core",
+        type: "subagent",
+      });
+
+      expect(result).toEqual({
+        id: "contextscout",
+        name: "ContextScout",
+        category: "subagents/core",
+        type: "subagent",
+        version: "1.0.0",
+        author: "opencode",
+        tags: [],
+        dependencies: [],
+        targets: ["opencode"],
+      });
+    });
+  });
+
+  describe("rejected blocks", () => {
+    it("rejects an unknown top-level key (strict)", () => {
+      const result = OacBlockSchema.safeParse({ ...VALID_BLOCK, colour: "blue" });
+
+      expect(result.success).toBe(false);
+    });
+
+    it("rejects targets: [] — a component that emits nowhere", () => {
+      const result = OacBlockSchema.safeParse({ ...VALID_BLOCK, targets: [] });
+
+      expect(result.success).toBe(false);
+    });
+
+    it("rejects an unknown target", () => {
+      const result = OacBlockSchema.safeParse({ ...VALID_BLOCK, targets: ["emacs"] });
+
+      expect(result.success).toBe(false);
+    });
+
+    it("rejects a non-kebab-case id", () => {
+      for (const id of ["OpenAgent", "open_agent", "open agent", "-openagent", ""]) {
+        expect(OacBlockSchema.safeParse({ ...VALID_BLOCK, id }).success).toBe(false);
+      }
+    });
+
+    it("rejects a missing id or name", () => {
+      const { id: _id, ...noId } = VALID_BLOCK;
+      const { name: _name, ...noName } = VALID_BLOCK;
+
+      expect(OacBlockSchema.safeParse(noId).success).toBe(false);
+      expect(OacBlockSchema.safeParse(noName).success).toBe(false);
+    });
+
+    it("rejects a non-SemVer version", () => {
+      for (const version of ["1.0", "v1.0.0", "latest"]) {
+        expect(OacBlockSchema.safeParse({ ...VALID_BLOCK, version }).success).toBe(false);
+      }
+    });
+
+    it("rejects an unknown category root", () => {
+      expect(OacBlockSchema.safeParse({ ...VALID_BLOCK, category: "kore" }).success).toBe(
+        false
+      );
+    });
+
+    it("rejects an untyped or unknown-typed dependency", () => {
+      for (const dep of ["contextscout", "wizard:merlin", ":contextscout"]) {
+        expect(
+          OacBlockSchema.safeParse({ ...VALID_BLOCK, dependencies: [dep] }).success
+        ).toBe(false);
+      }
+    });
+
+    it("rejects an unknown type", () => {
+      expect(OacBlockSchema.safeParse({ ...VALID_BLOCK, type: "plugin" }).success).toBe(
+        false
+      );
+    });
+  });
+
+  describe("the real corpus", () => {
+    it("accepts every entry in .opencode/config/agent-metadata.json", () => {
+      const entries = Object.entries(corpus());
+      expect(entries.length).toBeGreaterThan(0);
+
+      const rejected = entries
+        .map(([key, entry]) => ({ key, result: OacBlockSchema.safeParse(entry) }))
+        .filter(({ result }) => !result.success)
+        .map(({ key, result }) => `${key}: ${JSON.stringify(result.error?.issues)}`);
+
+      expect(rejected).toEqual([]);
+    });
+
+    it("covers every field the sidecar uses, so nothing is lost dissolving it", () => {
+      const used = new Set(Object.values(corpus()).flatMap((entry) => Object.keys(entry)));
+      const known = new Set(Object.keys(OacBlockSchema.shape));
+
+      expect([...used].filter((field) => !known.has(field))).toEqual([]);
+    });
+
+    it("round-trips sidecar dependency strings back to their authored form", () => {
+      for (const entry of Object.values(corpus())) {
+        const parsed = OacBlockSchema.parse(entry);
+        const reemitted = parsed.dependencies.map((dep) => `${dep.type}:${dep.id}`);
+
+        expect(reemitted).toEqual(entry.dependencies ?? []);
+      }
+    });
+  });
+});
+
+describe("OacCategorySchema", () => {
+  it("accepts the corpus categories, including the subagents/* paths", () => {
+    for (const category of ["core", "meta", "content", "data", "testing", "subagents/core"]) {
+      expect(OacCategorySchema.safeParse(category).success).toBe(true);
+    }
+  });
+
+  it("rejects deep paths and bad segments", () => {
+    for (const category of ["subagents/core/extra", "subagents/Core", "/core", "core/"]) {
+      expect(OacCategorySchema.safeParse(category).success).toBe(false);
+    }
+  });
+});
+
+describe("BuildTargetsSchema", () => {
+  it("defaults to opencode when omitted", () => {
+    expect(BuildTargetsSchema.parse(undefined)).toEqual(["opencode"]);
+  });
+
+  it("rejects an explicit empty list", () => {
+    expect(BuildTargetsSchema.safeParse([]).success).toBe(false);
+  });
+});
+
+describe("CanonicalAgentSchema", () => {
+  const AGENT = {
+    name: "OpenAgent",
+    description: "Universal coordination agent",
+    mode: "primary",
+    oac: VALID_BLOCK,
+  };
+
+  it("accepts OpenCode-legal frontmatter plus an oac block", () => {
+    const result = CanonicalAgentSchema.parse(AGENT);
+
+    expect(result.oac.id).toBe("openagent");
+    expect(result.mode).toBe("primary");
+  });
+
+  it("requires the oac block", () => {
+    const { oac: _oac, ...withoutOac } = AGENT;
+
+    expect(CanonicalAgentSchema.safeParse(withoutOac).success).toBe(false);
+  });
+
+  it("still requires OpenCode-legal frontmatter", () => {
+    const { description: _description, ...withoutDescription } = AGENT;
+
+    expect(CanonicalAgentSchema.safeParse(withoutDescription).success).toBe(false);
+  });
+
+  it("desugars authored permission map sugar into ordered rules", () => {
+    const result = CanonicalAgentSchema.parse({
+      ...AGENT,
+      permission: { edit: "deny", bash: { "*": "deny", "ls*": "allow" } },
+    });
+
+    expect(result.permission).toEqual([
+      { capability: "edit", rules: [{ pattern: "*", action: "deny" }] },
+      {
+        capability: "bash",
+        rules: [
+          { pattern: "*", action: "deny" },
+          { pattern: "ls*", action: "allow" },
+        ],
+      },
+    ]);
+  });
+});

+ 249 - 0
packages/compatibility-layer/tests/unit/types/Permission.test.ts

@@ -0,0 +1,249 @@
+/**
+ * Unit tests for the ORDERED permission representation and its desugaring.
+ *
+ * Why this matters: `GranularPermissionSchema` used to be `z.record(...)`, an unordered
+ * map. OpenCode resolves permissions LAST-MATCH-WINS — verified live against OpenCode
+ * 1.17.20 in docs/architecture/canonical-refactor/10-PRECEDENCE-EXPERIMENT.md, whose
+ * resolver is literally `.findLast()` over a flattened, ordered rule list. Order is
+ * therefore semantic, and a map only preserved it by accident of ECMAScript string-key
+ * insertion ordering — an accident that provably fails for integer-like keys (probe 3).
+ *
+ * The full resolver lives in Capabilities.ts (subtask 04). What is asserted here is the
+ * property that resolver depends on: desugaring is total and order-preserving, and the
+ * resulting order yields the outcomes the live experiment observed.
+ */
+
+import { describe, it, expect } from "vitest";
+import {
+  desugarPermission,
+  PermissionInputSchema,
+  GranularPermissionSchema,
+  PermissionMapSchema,
+  type GranularPermission,
+  type PermissionAction,
+} from "../../../src/types.js";
+
+/**
+ * Minimal last-match-wins resolver mirroring OpenCode's `evaluate()` (findLast over the
+ * flattened rules). Test-local on purpose: subtask 04 owns the shipped resolver; this only
+ * demonstrates that the schema's ORDER carries the semantics.
+ */
+function resolve(
+  permissions: GranularPermission,
+  capability: string,
+  candidate: string
+): PermissionAction | undefined {
+  const glob = (pattern: string, value: string): boolean =>
+    new RegExp(`^${pattern.split("*").map(escape).join(".*")}$`).test(value);
+
+  return permissions
+    .flatMap((entry) => entry.rules.map((rule) => ({ capability: entry.capability, ...rule })))
+    .findLast((rule) => glob(rule.capability, capability) && glob(rule.pattern, candidate))
+    ?.action;
+}
+
+function escape(literal: string): string {
+  return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+describe("GranularPermissionSchema", () => {
+  it("is an ordered array, not a record", () => {
+    const ordered = [
+      { capability: "bash", rules: [{ pattern: "*", action: "deny" as const }] },
+    ];
+
+    expect(GranularPermissionSchema.safeParse(ordered).success).toBe(true);
+    expect(GranularPermissionSchema.safeParse({ bash: "deny" }).success).toBe(false);
+  });
+
+  it("rejects an unknown key in a rule or entry (strict)", () => {
+    expect(
+      GranularPermissionSchema.safeParse([
+        { capability: "bash", rules: [{ pattern: "*", action: "deny", note: "x" }] },
+      ]).success
+    ).toBe(false);
+
+    expect(
+      GranularPermissionSchema.safeParse([{ capability: "bash", rules: [], extra: 1 }]).success
+    ).toBe(false);
+  });
+
+  it("rejects an unknown action and an empty pattern", () => {
+    expect(
+      GranularPermissionSchema.safeParse([
+        { capability: "bash", rules: [{ pattern: "*", action: "maybe" }] },
+      ]).success
+    ).toBe(false);
+
+    expect(
+      GranularPermissionSchema.safeParse([
+        { capability: "bash", rules: [{ pattern: "", action: "deny" }] },
+      ]).success
+    ).toBe(false);
+  });
+
+  it("represents duplicate patterns, which the map form cannot", () => {
+    const ordered = [
+      {
+        capability: "bash",
+        rules: [
+          { pattern: "echo dup*", action: "deny" as const },
+          { pattern: "echo dup*", action: "allow" as const },
+        ],
+      },
+    ];
+
+    // The experiment (probe 4) showed YAML silently collapses these to the last one.
+    // The ordered form keeps both; refusing to EMIT them is the serializer's job.
+    expect(GranularPermissionSchema.parse(ordered)[0]?.rules).toHaveLength(2);
+  });
+});
+
+describe("desugarPermission", () => {
+  it("desugars scalar sugar to a single catch-all rule", () => {
+    expect(desugarPermission({ edit: "deny" })).toEqual([
+      { capability: "edit", rules: [{ pattern: "*", action: "deny" }] },
+    ]);
+  });
+
+  it("desugars boolean sugar (true = allow, false = deny)", () => {
+    expect(desugarPermission({ read: true, write: false })).toEqual([
+      { capability: "read", rules: [{ pattern: "*", action: "allow" }] },
+      { capability: "write", rules: [{ pattern: "*", action: "deny" }] },
+    ]);
+  });
+
+  it("desugars map sugar preserving authored key order", () => {
+    expect(
+      desugarPermission({ bash: { "*": "deny", "git status*": "allow", "rm *": "deny" } })
+    ).toEqual([
+      {
+        capability: "bash",
+        rules: [
+          { pattern: "*", action: "deny" },
+          { pattern: "git status*", action: "allow" },
+          { pattern: "rm *", action: "deny" },
+        ],
+      },
+    ]);
+  });
+
+  it("mixes scalar and map sugar without reordering", () => {
+    const result = desugarPermission({
+      read: "allow",
+      bash: { "*": "deny", "ls*": "allow" },
+      edit: "ask",
+    });
+
+    expect(result.map((entry) => entry.capability)).toEqual(["read", "bash", "edit"]);
+  });
+
+  it("is the identity on already-ordered input", () => {
+    const ordered = [
+      {
+        capability: "bash",
+        rules: [
+          { pattern: "*", action: "deny" as const },
+          { pattern: "ls*", action: "allow" as const },
+        ],
+      },
+    ];
+
+    expect(desugarPermission(ordered)).toEqual(ordered);
+  });
+
+  it("accepts a wildcard capability, which OpenCode flattens alongside specific ones", () => {
+    expect(desugarPermission({ "*": "ask" })).toEqual([
+      { capability: "*", rules: [{ pattern: "*", action: "ask" }] },
+    ]);
+  });
+
+  it("rejects an integer-like scope, which ECMAScript would silently reorder", () => {
+    // Probe 3: `{"*": "deny", "8080": "allow"}` reorders to `[["8080","allow"],["*","deny"]]`,
+    // making the allow unreachable — and OpenCode then dropped the bash tool entirely.
+    expect(() => desugarPermission({ bash: { "*": "deny", "8080": "allow" } })).toThrow();
+    expect(PermissionInputSchema.safeParse({ "8080": "allow" }).success).toBe(false);
+  });
+
+  it("allows an integer-like scope in the ordered form, where order is explicit", () => {
+    const ordered = [
+      { capability: "bash", rules: [{ pattern: "8080", action: "allow" as const }] },
+    ];
+
+    expect(desugarPermission(ordered)).toEqual(ordered);
+  });
+
+  it("keeps the legacy map form parseable, so existing frontmatter still loads", () => {
+    const authored = { bash: { "*": "deny", "ls*": "allow" }, edit: "deny" };
+
+    expect(PermissionMapSchema.safeParse(authored).success).toBe(true);
+    expect(desugarPermission(authored)).toHaveLength(2);
+  });
+});
+
+describe("last-match-wins resolution order", () => {
+  it("lets a later specific rule override an earlier broad one (probe 1)", () => {
+    const permissions = desugarPermission({ bash: { "*": "deny", "echo ok*": "allow" } });
+
+    expect(resolve(permissions, "bash", "echo ok probe1")).toBe("allow");
+    expect(resolve(permissions, "bash", "ls")).toBe("deny");
+  });
+
+  it("lets a later broad rule override an earlier specific one (probe 2)", () => {
+    // The distinguishing case: most-specific-wins predicts deny; the live install allowed.
+    const permissions = desugarPermission({ bash: { "echo ok*": "deny", "*": "allow" } });
+
+    expect(resolve(permissions, "bash", "echo ok probe2")).toBe("allow");
+  });
+
+  it("resolves the real openagent.md bash block", () => {
+    const permissions = desugarPermission({
+      bash: {
+        "*": "ask",
+        "rm -rf *": "ask",
+        "rm -rf /*": "deny",
+        "sudo *": "deny",
+        "> /dev/*": "deny",
+      },
+    });
+
+    expect(resolve(permissions, "bash", "sudo apt install")).toBe("deny");
+    expect(resolve(permissions, "bash", "ls")).toBe("ask");
+    expect(resolve(permissions, "bash", "rm -rf build")).toBe("ask");
+    // Matches both "rm -rf *" (ask) and the later "rm -rf /*" (deny) — the later rule wins.
+    expect(resolve(permissions, "bash", "rm -rf /tmp")).toBe("deny");
+  });
+
+  it("resolves the real coder-agent.md deny-all-then-allowlist bash block", () => {
+    const permissions = desugarPermission({
+      bash: {
+        "*": "deny",
+        "bash .opencode/skills/task-management/router.sh complete*": "allow",
+        "bash .opencode/skills/task-management/router.sh status*": "allow",
+      },
+    });
+
+    expect(
+      resolve(permissions, "bash", "bash .opencode/skills/task-management/router.sh status x")
+    ).toBe("allow");
+    expect(resolve(permissions, "bash", "curl evil.sh")).toBe("deny");
+  });
+
+  it("returns undefined when no rule matches, so the caller applies a default", () => {
+    // The IR must NOT hardcode allow here: OpenCode's own fallback is `ask`, with `allow`
+    // supplied by a preceding global baseline rule. Ownership of the default is the
+    // resolver's (subtask 04), not the schema's.
+    const permissions = desugarPermission({ bash: { "ls*": "allow" } });
+
+    expect(resolve(permissions, "bash", "rm -rf /")).toBeUndefined();
+    expect(resolve(permissions, "edit", "src/a.ts")).toBeUndefined();
+  });
+
+  it("reordering the authored rules changes the outcome — order is semantic", () => {
+    const denyFirst = desugarPermission({ bash: { "*": "deny", "ls*": "allow" } });
+    const allowFirst = desugarPermission({ bash: { "ls*": "allow", "*": "deny" } });
+
+    expect(resolve(denyFirst, "bash", "ls -la")).toBe("allow");
+    expect(resolve(allowFirst, "bash", "ls -la")).toBe("deny");
+  });
+});

+ 1 - 1
packages/compatibility-layer/tsconfig.json

@@ -2,7 +2,7 @@
   "compilerOptions": {
     /* Language and Environment */
     "target": "ES2022",
-    "lib": ["ES2022"],
+    "lib": ["ES2023"],
     
     /* Modules */
     "module": "ESNext",