/*
RFC-0286: the Agent Surface Manifest — the single generated artifact every
protocol projection (knowledge files, OpenAPI, MCP) derives from (AS-1). Pure,
deterministic assembly: no I/O, no timestamps, sorted-key canonical hashing so
two runs on unchanged input are byte-identical (AS-7).
- Do not read files or content — callers (kernel commands) load and pass data.
- Do not sign — proof is always null here; RFC-0308 adds signing over the
domain-separated content hash payload via agent.surface.sign.
- RFC-1113: supportedProtocols comment update — unconditional ARD emission now spans ard.json + ai-catalog.json, agent-skills spans index + per-domain SKILL.md.
- RFC-1187: phases 5-7 — OAuth route/discovery generators, env schema, operator commands
- RFC-1187: fo-review — interfaces.oauth carries keyVersion (JWT/JWKS kid follows passport rotation)
- RFC-1188: Web Bot Auth outbound identity: wba-sign primitives, directory emit+sweep, AGS-WBA-01, leitstand signed fetches
- RFC-1211: interfaces.mcpUi + supportedProtocols "mcp-ui" — advertised only when the MCP route is live and the ui:// emission set is non-empty.
RFC-0286, RFC-0954, RFC-1076, RFC-1112, RFC-1113, RFC-1114, RFC-1115, RFC-1165, RFC-1171
*/
import { createHash } from "node:crypto";
import type { AgentSurfaceProof } from "./proof.ts";
import type { CapabilityPricing } from "./capability-schema.ts";
/** Semver of THIS manifest schema. Bump only via an RFC amending RFC-0286 (AS-7). */
export const AGENT_SURFACE_VERSION = "2.0.0";
/** RFC-1113: $schema identifier for the Agent Surface Manifest document. */
export const AGENT_SURFACE_SCHEMA_ID = "gogol.agent.surface/2";
/**
* RFC-1113: closed vocabulary for AgentSurfaceManifest.supportedProtocols.
* Every value MUST correspond to a discovery artifact or interface the surface
* actually emits — agent.surface.validate enforces the honesty invariant
* (AGS-DISCOVERY-01).
*/
export type AgentProtocol =
| "mcp"
| "a2a"
| "openapi"
| "linkset"
| "llms"
| "twins"
| "ard"
| "dns-aid"
| "agent-skills"
| "schemamap"
| "nlweb"
| "oauth"
| "webBotAuth"
| "mcp-ui"
| "search";
export interface AgentKnowledgeRef {
/** A BusinessDomain with visibility "public" (RFC-0287). */
domain: string;
url: string;
/** e.g. "gogol.agent.knowledge/offer@1". */
schema: string;
}
export interface AgentActionRef {
/** Capability catalog id, e.g. "lead.submit" (RFC-0288). */
id: string;
url: string;
title: Record;
inputSchemaRef: string;
entitlement: "agent.actions";
/**
* RFC-1169: "none" marks a preview capability (validation failures answer
* 200 ready:false, no dispatch). Optional — manifests deployed before
* RFC-1169 lack it; absent reads as "write".
*/
sideEffect?: "write" | "none";
/**
* RFC-1212: machine-readable pricing — present iff the capability declares
* `metering` in the closed catalog (bijective projection, AGM-04 enforces).
* Optional — manifests generated before RFC-1212 lack it.
*/
pricing?: CapabilityPricing;
}
export interface AgentClaimRef {
id: string;
claimClass: string;
statement: string;
verificationLevel?: string;
evidenceCount: number;
}
/**
* RFC-1171: closed deprecation declaration — every deprecated machine path
* carries a successor, a human/model-readable reason, and a calendar window.
* The schema is closed; extending it requires an RFC (DNA-19 principle).
*/
export interface ManifestDeprecation {
/** Deprecated path pattern, e.g. "/api/agent/actions/{id}". */
path: string;
/** Successor path pattern, e.g. "/api/agent/v1/actions/{id}". */
successor: string;
/** Human + model readable migration note. */
reason: string;
/** ISO date (YYYY-MM-DD) — Deprecation header appears from this date on. */
deprecateAt: string;
/** ISO date (YYYY-MM-DD) — the alias is removed no earlier than this. */
sunsetAt: string;
}
/** RFC-1171: canonical versioned action path pattern. */
export const AGENT_ACTION_V1_PATH = "/api/agent/v1/actions/{id}";
/** RFC-1171: the deprecated unversioned alias path pattern. */
export const AGENT_ACTION_ALIAS_PATH = "/api/agent/actions/{id}";
/**
* RFC-1171: the platform-injected deprecation entry for the unversioned action
* namespace. Dates are FIXED constants — never derive them from build time;
* generatedAt is excluded from contentHash, but a changing deprecateAt would
* poison the deterministic hash (AS-7, DNA-58). 90-day minimum window honored.
*/
export const AGENT_ACTION_DEPRECATION: ManifestDeprecation = {
path: AGENT_ACTION_ALIAS_PATH,
successor: AGENT_ACTION_V1_PATH,
reason: "Actions moved into the versioned namespace (RFC-1171).",
deprecateAt: "2026-10-01",
sunsetAt: "2027-04-01",
};
/** RFC-1171: minimum sunset window in milliseconds (90 days). */
export const DEPRECATION_MIN_WINDOW_MS = 90 * 24 * 60 * 60 * 1000;
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
/**
* RFC-1171: validate `deprecations[]` entries against the emitted action set.
* Returns a list of violations (empty = valid). Pure — callers decide whether
* to throw (builder, fail-closed at generation) or report (surface validator).
*
* Rules: ISO dates; successor must resolve — every emitted action url must
* equal the successor pattern with `{id}` substituted; the deprecated path
* must not itself be a live canonical url; sunsetAt - deprecateAt >= 90 days.
*/
export function validateManifestDeprecations(
deprecations: ManifestDeprecation[],
actions: AgentActionRef[],
): string[] {
const errors: string[] = [];
for (const dep of deprecations) {
if (!ISO_DATE_RE.test(dep.deprecateAt) || Number.isNaN(Date.parse(dep.deprecateAt))) {
errors.push(
`deprecation '${dep.path}': deprecateAt '${dep.deprecateAt}' is not a valid YYYY-MM-DD date`,
);
}
if (!ISO_DATE_RE.test(dep.sunsetAt) || Number.isNaN(Date.parse(dep.sunsetAt))) {
errors.push(
`deprecation '${dep.path}': sunsetAt '${dep.sunsetAt}' is not a valid YYYY-MM-DD date`,
);
}
if (!dep.path.startsWith("/api/agent/") || !dep.successor.startsWith("/api/agent/")) {
errors.push(`deprecation '${dep.path}': path and successor must live under /api/agent/`);
}
if (dep.path === dep.successor) {
errors.push(`deprecation '${dep.path}': successor equals the deprecated path`);
}
if (
!Number.isNaN(Date.parse(dep.deprecateAt)) &&
!Number.isNaN(Date.parse(dep.sunsetAt)) &&
Date.parse(dep.sunsetAt) - Date.parse(dep.deprecateAt) < DEPRECATION_MIN_WINDOW_MS
) {
errors.push(
`deprecation '${dep.path}': sunsetAt is less than 90 days after deprecateAt (window: ${dep.deprecateAt} → ${dep.sunsetAt})`,
);
}
if (actions.length === 0) {
errors.push(
`deprecation '${dep.path}': no emitted actions to resolve successor '${dep.successor}'`,
);
continue;
}
for (const action of actions) {
const resolved = dep.successor.replaceAll("{id}", action.id);
if (resolved !== action.url) {
errors.push(
`deprecation '${dep.path}': successor '${dep.successor}' does not resolve emitted action '${action.id}' ('${resolved}' != '${action.url}')`,
);
}
const deprecatedResolved = dep.path.replaceAll("{id}", action.id);
if (deprecatedResolved === action.url) {
errors.push(
`deprecation '${dep.path}': deprecated path resolves to the canonical url of '${action.id}' — a live path cannot be deprecated`,
);
}
}
}
return errors;
}
export interface AgentSurfaceManifest {
/** RFC-1113: schema identifier — "gogol.agent.surface/2". */
$schema: string;
surfaceVersion: string;
site: string;
baseUrl: string;
languages: { default: string; supported: string[] };
/** RFC-1113: ISO 8601 build timestamp — excluded from contentHash + signing payload. */
generatedAt: string;
/** RFC-1113: closed vocabulary — only the protocols this surface actually emits. */
supportedProtocols: AgentProtocol[];
/** sha256 hex over sorted-key JSON of this document minus contentHash + proof + generatedAt. */
contentHash: string;
knowledge: AgentKnowledgeRef[];
actions: AgentActionRef[];
/**
* RFC-1171: declarative deprecation — closed declaration projected into
* agent.json, OpenAPI, api-catalog, and gate-stamped response headers.
* Platform-injected; sites never author it.
*/
deprecations: ManifestDeprecation[];
/** RFC-1076: claim provenance chain — claims with their evidence references. */
claims: AgentClaimRef[];
interfaces: {
llms: string;
/** RFC-1164: the per-page JSON-LD mirror index — unconditional on an enabled
* surface (same precedent as `llms`: the surface pipeline always emits
* schemamap.xml; SMAP-03 verifies the artifact shipped). */
schemamap: string;
twins: { pattern: string } | null;
openapi: string | null;
mcp: { url: string; protocolVersion: string } | null;
/** RFC-1114: the A2A endpoint — null when the surface ships no A2A route. */
a2a: { url: string; protocolVersion: string } | null;
search: { url: string; model: string; dimensions: number } | null;
/** RFC-1165: the NLWeb /ask endpoint — populated iff `search` is (the SSR
* adapter + search-manifest chain gates both; static sites ship no /ask). */
nlweb: string | null;
/**
* RFC-1187: OAuth discovery for the worker-hosted authorization server —
* populated iff `agent.auth.enabled` AND the signing key was configured at
* generation time; null preserves the anonymous contract (ADR-0099 until
* superseded). `keyVersion` is the active passport key version stamped at
* generation — JWT `kid` and the JWKS `kid` both source it so
* `passport.key.rotate` transitions stay honest.
*/
oauth: {
authorizationServer: string;
protectedResource: string;
keyVersion: string;
} | null;
/**
* RFC-1188: the Web Bot Auth key directory — populated iff a passport
* signing key was configured at generation time; null means the site
* signs no outbound requests and advertises none.
*/
webBotAuth: { directory: string } | null;
/**
* RFC-1211: MCP-UI resource surface — populated iff `interfaces.mcp` is
* live AND at least one ui:// artifact resolves (≥1 active capability
* declares `ui.resource`). `url` is the browsable public directory of the
* committed artifacts; `uriPrefix` is the scheme prefix used in
* resources/list. null = honest absence (partial agent surface or no
* ui-capable capability — AGS-DISCOVERY-01/AGUI-04 enforce).
*/
mcpUi: { url: string; uriPrefix: string } | null;
};
/** Null until agent.surface.sign signs it (RFC-0308) or when no key material exists. */
proof: AgentSurfaceProof | null;
}
export interface AgentSurfaceManifestInput {
site: string;
baseUrl: string;
languages: { default: string; supported: string[] };
/** RFC-1113: ISO 8601 build timestamp supplied by the caller (the builder stays pure). */
generatedAt: string;
knowledge?: AgentKnowledgeRef[];
actions?: AgentActionRef[];
/** RFC-1076: claim provenance summaries for the manifest. */
claims?: AgentClaimRef[];
/**
* RFC-1171: extra deprecation declarations (platform code only — never
* site-authored). The unversioned→v1 action entry is injected automatically
* when actions exist; do not repeat it here.
*/
deprecations?: ManifestDeprecation[];
hasTwins?: boolean;
openapiUrl?: string | null;
mcp?: { url: string; protocolVersion: string } | null;
/** RFC-1114: the A2A endpoint descriptor — omit/null when no A2A route exists. */
a2a?: { url: string; protocolVersion: string } | null;
search?: { url: string; model: string; dimensions: number } | null;
/** RFC-1165: NLWeb /ask path — set when the search interface chain serves. */
nlweb?: string | null;
/**
* RFC-1187: OAuth discovery pair — the well-known document paths this site
* emits (`/.well-known/oauth-authorization-server` +
* `/.well-known/oauth-protected-resource`) plus the active passport
* `keyVersion` stamped at generation. Set only when the AS is live.
*/
oauth?: {
authorizationServer: string;
protectedResource: string;
keyVersion: string;
} | null;
/**
* RFC-1188: WBA key directory advertisement — the well-known path the
* generator emits (`/.well-known/http-message-signatures-directory`).
* Set only when a passport signing key was configured at generation.
*/
webBotAuth?: { directory: string } | null;
/**
* RFC-1211: MCP-UI advertisement — set only when the MCP route is live and
* the ui:// emission set is non-empty (deterministic sibling of
* agent.ui.generate — same precedent as openapiUrl/mcp).
*/
mcpUi?: { url: string; uriPrefix: string } | null;
}
// ---------------------------------------------------------------------------
// Canonicalization — shared by hashing here and by signing in RFC-0308
// ---------------------------------------------------------------------------
function sortKeysDeep(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortKeysDeep);
if (value !== null && typeof value === "object") {
const out: Record = {};
for (const key of Object.keys(value as Record).sort()) {
out[key] = sortKeysDeep((value as Record)[key]);
}
return out;
}
return value;
}
/** Deterministic sorted-key JSON serialization — no whitespace, stable key order. */
export function canonicalJson(value: unknown): string {
return JSON.stringify(sortKeysDeep(value));
}
/**
* sha256 hex over the canonical JSON of `doc` with `contentHash`, `proof`, and any
* additional `excludeFields` removed. All signed manifest hash functions MUST use
* this helper to ensure the `proof` field is always excluded — a missing exclusion
* causes contentHash drift after signing (AGS-SEARCH-07 pattern).
*/
export function computeSignedContentHash(
doc: Record,
excludeFields: string[] = [],
): string {
const exclusions = new Set(["contentHash", "proof", ...excludeFields]);
const rest: Record = {};
for (const [key, value] of Object.entries(doc)) {
if (!exclusions.has(key)) {
rest[key] = value;
}
}
return createHash("sha256").update(canonicalJson(rest), "utf8").digest("hex");
}
/**
* sha256 hex over the canonical JSON of `doc` with `contentHash`, `proof`, and
* `generatedAt` removed. RFC-1113: `generatedAt` is a build timestamp — including
* it would produce a spurious hash (and signature) change on every rebuild.
*/
export function computeAgentManifestContentHash(doc: Record): string {
return computeSignedContentHash(doc, ["generatedAt"]);
}
// ---------------------------------------------------------------------------
// Builder
// ---------------------------------------------------------------------------
/** Pure: assemble + hash the manifest. Deterministic — sorts refs and languages. */
export function buildAgentSurfaceManifest(input: AgentSurfaceManifestInput): AgentSurfaceManifest {
// RFC-1113: honesty invariant — supportedProtocols lists only what the surface
// emits. The unconditional entries are the artifacts every enabled agent
// surface generates (llms.txt, api-catalog linkset, ARD catalog documents
// at .well-known/ard.json + ai-catalog.json, DNS-AID record, agent-skills
// index + per-domain SKILL.md); the conditional ones follow interfaces.
const supportedProtocols: AgentProtocol[] = (
[
"agent-skills",
"ard",
"dns-aid",
"linkset",
"llms",
"schemamap",
...(input.mcp ? ["mcp"] : []),
...(input.a2a ? ["a2a"] : []),
...(input.openapiUrl ? ["openapi"] : []),
...(input.search ? ["search"] : []),
...(input.nlweb ? ["nlweb"] : []),
...(input.oauth ? ["oauth"] : []),
...(input.webBotAuth ? ["webBotAuth"] : []),
...(input.mcpUi ? ["mcp-ui"] : []),
...(input.hasTwins ? ["twins"] : []),
] as AgentProtocol[]
).sort();
const base: Omit = {
$schema: AGENT_SURFACE_SCHEMA_ID,
surfaceVersion: AGENT_SURFACE_VERSION,
site: input.site,
baseUrl: input.baseUrl.replace(/\/+$/, ""),
generatedAt: input.generatedAt,
supportedProtocols,
languages: {
default: input.languages.default,
supported: [...input.languages.supported].sort(),
},
knowledge: [...(input.knowledge ?? [])].sort((a, b) => a.domain.localeCompare(b.domain)),
actions: [...(input.actions ?? [])].sort((a, b) => a.id.localeCompare(b.id)),
deprecations: mergeDeprecations(input),
claims: [...(input.claims ?? [])].sort((a, b) => a.id.localeCompare(b.id)),
interfaces: {
llms: "/llms.txt",
schemamap: "/schemamap.xml",
twins: input.hasTwins ? { pattern: "/**.md" } : null,
openapi: input.openapiUrl ?? null,
mcp: input.mcp ?? null,
a2a: input.a2a ?? null,
search: input.search ?? null,
nlweb: input.nlweb ?? null,
oauth: input.oauth ?? null,
webBotAuth: input.webBotAuth ?? null,
mcpUi: input.mcpUi ?? null,
},
proof: null,
};
// RFC-1171: fail-closed at generation — an unresolving successor or a
// sub-90-day window must never reach a deployed agent.json (AC-3, AC-4).
const deprecationErrors = validateManifestDeprecations(base.deprecations, base.actions);
if (deprecationErrors.length > 0) {
throw new Error(
`agent surface manifest: invalid deprecations[] — ${deprecationErrors.join("; ")}`,
);
}
const contentHash = computeAgentManifestContentHash(base as unknown as Record);
return { ...base, contentHash };
}
/**
* RFC-1171: platform injection — the unversioned→v1 action deprecation is
* declared automatically when the surface emits actions; caller-provided
* entries (platform code only) merge on top, deduplicated by path.
*/
function mergeDeprecations(input: AgentSurfaceManifestInput): ManifestDeprecation[] {
const list = [...(input.deprecations ?? [])];
if ((input.actions?.length ?? 0) > 0 && !list.some((d) => d.path === AGENT_ACTION_ALIAS_PATH)) {
list.push(AGENT_ACTION_DEPRECATION);
}
return list.sort((a, b) => a.path.localeCompare(b.path));
}