diff --git a/packages/agent/package.json b/packages/agent/package.json index b787023002..e1b0667c16 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -30,6 +30,12 @@ "./dist/rfc64/inventory-v1/statements.js": "./dist/rfc64/inventory-v1/statements.js", "./dist/rfc64/public-catalog-activation-config-v1.js": "./dist/rfc64/public-catalog-activation-config-v1.js", "./dist/rfc64/swm-author-inventory-producer-v1.js": "./dist/rfc64/swm-author-inventory-producer-v1.js", + "./dist/system-records/agent-profile-producer-v1.js": "./dist/system-records/agent-profile-producer-v1.js", + "./dist/system-records/artifact-v1.js": "./dist/system-records/artifact-v1.js", + "./dist/system-records/in-memory-agent-profile-publication-store-v1.js": "./dist/system-records/in-memory-agent-profile-publication-store-v1.js", + "./dist/system-records/provider-v1.js": "./dist/system-records/provider-v1.js", + "./dist/system-records/transport-v1.js": "./dist/system-records/transport-v1.js", + "./dist/system-records/*": null, "./dist/rfc64/control-envelope-signer-v1.js": null, "./dist/rfc64/swm-inventory-shadow-runtime-v1.js": null, "./dist/rfc64/control-object-store-v1-internal.js": null, diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index 09aaafeb94..fff2fc3755 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -8,6 +8,9 @@ const legacyAgent = await import('@origintrail-official/dkg-agent/dist/dkg-agent const publicCatalogActivation = await import( '@origintrail-official/dkg-agent/rfc64/public-catalog-activation-config-v1' ); +const agentProfileProducer = await import( + '@origintrail-official/dkg-agent/dist/system-records/agent-profile-producer-v1.js' +); const require = createRequire(import.meta.url); const packageManifest = require('@origintrail-official/dkg-agent/package.json'); const expectedRfc64PolicyCells = [ @@ -16,6 +19,63 @@ const expectedRfc64PolicyCells = [ 'private-open', 'private-curated', ]; +const internalAgentProfilePhaseSpecifiers = [ + '@origintrail-official/dkg-agent/dist/system-records/agent-profile-producer-api-v1.js', + '@origintrail-official/dkg-agent/dist/system-records/agent-profile-producer-artifacts-v1-internal.js', + '@origintrail-official/dkg-agent/dist/system-records/agent-profile-producer-preparation-v1.js', + '@origintrail-official/dkg-agent/dist/system-records/agent-profile-producer-signing-v1.js', + '@origintrail-official/dkg-agent/dist/system-records/agent-profile-producer-inventory-v1.js', + '@origintrail-official/dkg-agent/dist/system-records/agent-profile-producer-commit-v1.js', +]; +const publicSystemRecordSpecifiers = [ + '@origintrail-official/dkg-agent/dist/system-records/agent-profile-producer-v1.js', + '@origintrail-official/dkg-agent/dist/system-records/artifact-v1.js', + '@origintrail-official/dkg-agent/dist/system-records/in-memory-agent-profile-publication-store-v1.js', + '@origintrail-official/dkg-agent/dist/system-records/provider-v1.js', + '@origintrail-official/dkg-agent/dist/system-records/transport-v1.js', +]; + +for (const specifier of internalAgentProfilePhaseSpecifiers) { + let resolved = false; + try { + await import(specifier); + resolved = true; + } catch (error) { + if (error?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') throw error; + } + if (resolved) throw new Error(`published package exposed internal phase ${specifier}`); +} + +for (const specifier of publicSystemRecordSpecifiers) { + const subpath = `.${specifier.slice('@origintrail-official/dkg-agent'.length)}`; + if (packageManifest.exports[subpath] !== subpath) { + throw new Error(`historical System Record module is not explicitly public: ${specifier}`); + } + await import(specifier); +} +if (packageManifest.exports['./dist/system-records/*'] !== null) { + throw new Error('unclassified System Record deep imports are not blocked by default'); +} + +const profileArtifact = (objectKind, byte) => Object.freeze({ + objectKind, + objectDigest: `0x${byte.toString(16).padStart(64, '0')}`, + canonicalBytes: Uint8Array.of(byte), +}); +const flattenedProfileArtifacts = agentProfileProducer + .flattenAgentProfileProducerPublicationArtifactsV1({ + head: profileArtifact('agent-profile-head', 1), + bundle: profileArtifact('profile-bundle', 2), + ownedSubjectTable: profileArtifact('owned-subject-table', 3), + inventoryObjects: Object.freeze([ + profileArtifact('inventory-internal', 4), + profileArtifact('inventory-leaf', 5), + ]), + }); +if (flattenedProfileArtifacts.map((artifact) => artifact.objectKind).join(',') + !== 'agent-profile-head,profile-bundle,owned-subject-table,inventory-internal,inventory-leaf') { + throw new Error('historical producer artifact-flattening export changed behavior'); +} if ( typeof root.DKGAgent !== 'function' diff --git a/packages/agent/src/profile.ts b/packages/agent/src/profile.ts index d91bd31357..65729a3f91 100644 --- a/packages/agent/src/profile.ts +++ b/packages/agent/src/profile.ts @@ -1,11 +1,12 @@ import type { Quad } from '@origintrail-official/dkg-storage'; import { - DKG_ONTOLOGY, SYSTEM_CONTEXT_GRAPHS, isPublicLikeAddress, } from '@origintrail-official/dkg-core'; import { + AGENT_PROFILE_SCHEMA_TERMS_V1, agentProfileIdentityFactsV1, + deriveAgentProfileOwnedSubjectV1, type AgentProfileIdentityFactsV1, type AgentProfileProjectionQuadV1, } from '@origintrail-official/dkg-core/system-record-v1'; @@ -77,12 +78,7 @@ export function collectPublishableMultiaddrs( return out; } -const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; -const SCHEMA = 'https://schema.org/'; -const DKG = 'https://dkg.network/ontology#'; -const ERC8004 = 'https://eips.ethereum.org/erc-8004#'; -const PROV = 'http://www.w3.org/ns/prov#'; -const SKILL = 'https://dkg.origintrail.io/skill#'; +const T = AGENT_PROFILE_SCHEMA_TERMS_V1; export const AGENT_REGISTRY_CONTEXT_GRAPH = SYSTEM_CONTEXT_GRAPHS.AGENTS; export const AGENT_REGISTRY_GRAPH = `did:dkg:context-graph:${AGENT_REGISTRY_CONTEXT_GRAPH}`; @@ -267,24 +263,24 @@ function buildAgentProfileModelV1(config: AgentProfileConfig): AgentProfileModel facts.push({ subject: s, predicate: p, object: o }); // Type: dkg:Agent + role-specific subclass - q(entity, RDF_TYPE, `${DKG}Agent`); - q(entity, RDF_TYPE, role === 'core' ? `${DKG}CoreNode` : `${DKG}EdgeNode`); + q(entity, T.rdfType, T.dkgAgent); + q(entity, T.rdfType, role === 'core' ? T.dkgCoreNode : T.dkgEdgeNode); // schema.org metadata - q(entity, `${SCHEMA}name`, `"${config.name}"`); + q(entity, T.schemaName, `"${config.name}"`); if (config.description) { - q(entity, `${SCHEMA}description`, `"${config.description}"`); + q(entity, T.schemaDescription, `"${config.description}"`); } // DKG P2P properties q(entity, identity.peerId.predicate, identity.peerId.object); - q(entity, `${DKG}nodeRole`, `"${role}"`); + q(entity, T.dkgNodeRole, `"${role}"`); if (identity.publicKey !== undefined) { q(entity, identity.publicKey.predicate, identity.publicKey.object); } if (config.relayAddress) { - q(entity, `${DKG}relayAddress`, `"${config.relayAddress}"`); + q(entity, T.dkgRelayAddress, `"${config.relayAddress}"`); } if (identity.agentAddress !== undefined) { q(entity, identity.agentAddress.predicate, identity.agentAddress.object); @@ -304,76 +300,76 @@ function buildAgentProfileModelV1(config: AgentProfileConfig): AgentProfileModel // contain quote characters; this guard is purely against // malformed callers. if (!ma || ma.includes('"')) continue; - q(entity, `${DKG}multiaddr`, `"${ma}"`); + q(entity, T.dkgMultiaddr, `"${ma}"`); } } - q(entity, `${DKG}lastSeen`, `"${profileTimestamp}"`); + q(entity, T.dkgLastSeen, `"${profileTimestamp}"`); // Encryption keys: prefer the multi-key array; fall back to the deprecated // singular fields only when the array isn't supplied (legacy callers / // test fixtures). Retired keys still get published so peers learn their // wallet-signed revocations and the resolver can prune them. if (config.encryptionKeys && config.encryptionKeys.length > 0) { for (const key of config.encryptionKeys) { - q(entity, `${DKG}publicEncryptionKey`, `"${key.publicEncryptionKey}"`); - q(entity, `${DKG}encryptionKeyAlgorithm`, `"${key.encryptionKeyAlgorithm}"`); - q(entity, `${DKG}encryptionKeyProof`, `"${key.encryptionKeyProof}"`); + q(entity, T.dkgPublicEncryptionKey, `"${key.publicEncryptionKey}"`); + q(entity, T.dkgEncryptionKeyAlgorithm, `"${key.encryptionKeyAlgorithm}"`); + q(entity, T.dkgEncryptionKeyProof, `"${key.encryptionKeyProof}"`); if (key.revokedAt && key.revocationProof) { - q(key.encryptionKeyId, `${DKG}revokedAt`, `"${key.revokedAt}"`); - q(key.encryptionKeyId, `${DKG}revokedBy`, entity); - q(key.encryptionKeyId, `${DKG}encryptionKeyRevocationProof`, `"${key.revocationProof}"`); + q(key.encryptionKeyId, T.dkgRevokedAt, `"${key.revokedAt}"`); + q(key.encryptionKeyId, T.dkgRevokedBy, entity); + q(key.encryptionKeyId, T.dkgEncryptionKeyRevocationProof, `"${key.revocationProof}"`); } } } else if (config.publicEncryptionKey && config.encryptionKeyAlgorithm && config.encryptionKeyProof) { - q(entity, `${DKG}publicEncryptionKey`, `"${config.publicEncryptionKey}"`); - q(entity, `${DKG}encryptionKeyAlgorithm`, `"${config.encryptionKeyAlgorithm}"`); - q(entity, `${DKG}encryptionKeyProof`, `"${config.encryptionKeyProof}"`); + q(entity, T.dkgPublicEncryptionKey, `"${config.publicEncryptionKey}"`); + q(entity, T.dkgEncryptionKeyAlgorithm, `"${config.encryptionKeyAlgorithm}"`); + q(entity, T.dkgEncryptionKeyProof, `"${config.encryptionKeyProof}"`); } if (config.framework) { - q(entity, `${SKILL}framework`, `"${config.framework}"`); + q(entity, T.skillFramework, `"${config.framework}"`); } // ERC-8004 capabilities (skills as capabilities) for (let i = 0; i < config.skills.length; i++) { const skill = config.skills[i]; - const capUri = `${entity}/.well-known/genid/cap${i + 1}`; + const capUri = deriveAgentProfileOwnedSubjectV1(entity, 'capability', i + 1); - q(entity, `${ERC8004}capabilities`, capUri); - q(capUri, RDF_TYPE, `${ERC8004}Capability`); - q(capUri, `${SCHEMA}name`, `"${skill.skillType}"`); + q(entity, T.erc8004Capabilities, capUri); + q(capUri, T.rdfType, T.erc8004Capability); + q(capUri, T.schemaName, `"${skill.skillType}"`); // Keep backward-compatible skill offering triples - const offeringUri = `${entity}/.well-known/genid/offering${i + 1}`; - q(entity, `${SKILL}offersSkill`, offeringUri); - q(offeringUri, RDF_TYPE, `${SKILL}SkillOffering`); - q(offeringUri, `${SKILL}skill`, `${SKILL}${skill.skillType}`); + const offeringUri = deriveAgentProfileOwnedSubjectV1(entity, 'offering', i + 1); + q(entity, T.skillOffersSkill, offeringUri); + q(offeringUri, T.rdfType, T.skillSkillOffering); + q(offeringUri, T.skillSkill, `${T.skillNamespace}${skill.skillType}`); if (skill.pricePerCall !== undefined) { - q(offeringUri, `${SKILL}pricePerCall`, `"${skill.pricePerCall}"`); + q(offeringUri, T.skillPricePerCall, `"${skill.pricePerCall}"`); } if (skill.currency) { - q(offeringUri, `${SKILL}currency`, `"${skill.currency}"`); + q(offeringUri, T.skillCurrency, `"${skill.currency}"`); } if (skill.successRate !== undefined) { - q(offeringUri, `${SKILL}successRate`, `"${skill.successRate}"`); + q(offeringUri, T.skillSuccessRate, `"${skill.successRate}"`); } if (skill.pricingModel) { - q(offeringUri, `${SKILL}pricing`, `${SKILL}${skill.pricingModel}`); + q(offeringUri, T.skillPricing, `${T.skillNamespace}${skill.pricingModel}`); } } // PROV provenance - const activityUri = `${entity}/.well-known/genid/registration`; - q(entity, `${PROV}wasGeneratedBy`, activityUri); - q(activityUri, RDF_TYPE, `${PROV}Activity`); - q(activityUri, `${PROV}atTime`, `"${profileTimestamp}"`); + const activityUri = deriveAgentProfileOwnedSubjectV1(entity, 'registration'); + q(entity, T.provWasGeneratedBy, activityUri); + q(activityUri, T.rdfType, T.provActivity); + q(activityUri, T.provAtTime, `"${profileTimestamp}"`); const served = config.contextGraphsServed; if (served?.length) { - const hostingUri = `${entity}/.well-known/genid/hosting`; - q(entity, `${SKILL}hostingProfile`, hostingUri); - q(hostingUri, RDF_TYPE, `${SKILL}HostingProfile`); + const hostingUri = deriveAgentProfileOwnedSubjectV1(entity, 'hosting'); + q(entity, T.skillHostingProfile, hostingUri); + q(hostingUri, T.rdfType, T.skillHostingProfileType); for (const cg of served) { - q(hostingUri, `${SKILL}contextGraphsServed`, `"${cg}"`); + q(hostingUri, T.skillContextGraphsServed, `"${cg}"`); } } diff --git a/packages/agent/src/system-records/agent-profile-producer-api-v1.ts b/packages/agent/src/system-records/agent-profile-producer-api-v1.ts new file mode 100644 index 0000000000..cd760b5c27 --- /dev/null +++ b/packages/agent/src/system-records/agent-profile-producer-api-v1.ts @@ -0,0 +1,133 @@ +import { + type AssertionCoordinateV1, + type CanonicalGraphScopedAuthorSealV1, + type CatalogSealDeploymentProfileV1, +} from '@origintrail-official/dkg-core'; +import { + type AgentProfileActiveHeadObjectV1, + type AgentProfileVerifiedAuthoritySummaryV1, + type Digest32V1, + type NetworkIdV1, + type OwnedSubjectTableObjectV1, + type SignedAgentProfileHeadEnvelopeV1, + type SignedSystemRecordRootDescriptorEnvelopeV1, + type SystemRecordInventoryTreeSnapshotV1, + type SystemRecordObjectKindV1, + type SystemRecordPeerPublicKeyV1, +} from '@origintrail-official/dkg-core/system-record-v1'; +import type { Quad } from '@origintrail-official/dkg-storage'; + +import type { EvmPersonalMessageSignerV1 } from '../evm-message-signer-v1.js'; +import type { PreparedAgentProfileV1 } from '../profile.js'; +import type { SystemRecordArtifactV1 } from './artifact-v1.js'; + +/** Stable producer boundary; implementation-phase capabilities stay in their owning modules. */ +export interface SystemRecordPeerSignerV1 { + readonly peerId: string; + readonly publicKey: SystemRecordPeerPublicKeyV1; + sign(message: Uint8Array): Promise; +} + +export type AgentProfilePublicationStatusV1 = 'tentative' | 'confirmed' | 'failed'; + +/** Untrusted legacy-publication result accepted at the producer boundary. */ +export interface AgentProfilePublicationBindingV1 { + readonly publicationStatus: AgentProfilePublicationStatusV1; + readonly assertionCoordinate: AssertionCoordinateV1; + readonly seal: Readonly; + readonly issuedAt: string; + readonly validUntil: string; + readonly projectionSchemaDigest: Digest32V1; +} + +export interface AgentProfileProducerInstallInputV1 { + readonly head: AgentProfileActiveHeadObjectV1; + readonly envelope: SignedAgentProfileHeadEnvelopeV1; + readonly canonicalProjectionBytes: Uint8Array; + readonly projectionQuads: readonly Readonly[]; + readonly ownedSubjectTable: OwnedSubjectTableObjectV1; + readonly verifiedAuthoritySummary: AgentProfileVerifiedAuthoritySummaryV1; + readonly signal: AbortSignal; +} + +export interface AgentProfileProducerPublicationV1 { + readonly headDigest: Digest32V1; + readonly rootDescriptorDigest: Digest32V1; + readonly version: string; + readonly authoritySequence: string; + readonly inventoryWrites: number; + readonly inventoryWriteBytes: number; +} + +export interface AgentProfileProducerPublicationCommitV1 { + /** Snapshot preconditions reserved before materialization begins. */ + readonly expectedHeadDigest: Digest32V1 | null; + readonly expectedRootDescriptorDigest: Digest32V1 | null; + readonly publicationArtifacts: AgentProfileProducerPublicationArtifactsV1; + readonly inventory: SystemRecordInventoryTreeSnapshotV1; + readonly rootEnvelope: SignedSystemRecordRootDescriptorEnvelopeV1; +} + +export type AgentProfileProducerArtifactV1 = Readonly< + Omit & { objectKind: Kind } +>; + +export interface AgentProfileProducerPublicationArtifactsV1 { + readonly head: AgentProfileProducerArtifactV1<'agent-profile-head'>; + readonly bundle: AgentProfileProducerArtifactV1<'profile-bundle'>; + readonly ownedSubjectTable: AgentProfileProducerArtifactV1<'owned-subject-table'>; + readonly inventoryObjects: readonly AgentProfileProducerArtifactV1< + 'inventory-internal' | 'inventory-leaf' + >[]; +} + +export interface AgentProfileProducerPublicationCommitLeaseV1 { + commit(): void | Promise; + abort(): void; +} + +export interface AgentProfileProducerPublicationStoreV1 { + snapshot(): Readonly<{ + inventory: SystemRecordInventoryTreeSnapshotV1 | null; + currentHead: SignedAgentProfileHeadEnvelopeV1 | null; + }>; + /** Resolve retained authority history by content address, without wire semantics. */ + resolveArtifact( + reference: Pick, + ): SystemRecordArtifactV1 | null | Promise; + /** Atomically verify and reserve the expected snapshot until commit or abort. */ + prepareCommit( + input: AgentProfileProducerPublicationCommitV1, + ): AgentProfileProducerPublicationCommitLeaseV1 | Promise; +} + +export interface CreateAgentProfileProducerOptionsV1 { + readonly networkId: NetworkIdV1; + /** Locally pinned VM publication lane; never derived from an untrusted seal. */ + readonly publicationDeployment: Readonly; + readonly peerSigner: SystemRecordPeerSignerV1; + readonly evmSigner: EvmPersonalMessageSignerV1; + readonly store: AgentProfileProducerPublicationStoreV1; + /** Independent verifier clock; publication timestamps are untrusted input. */ + readonly nowMs?: () => number; + /** Storage-runtime bridge: fence before publish; successful install commits advertisement. */ + readonly fence: ( + prepared: PreparedAgentProfileV1, + signal: AbortSignal, + ) => void | Promise; + readonly install: (input: AgentProfileProducerInstallInputV1) => void | Promise; +} + +export interface AgentProfileProducerLeaseV1 { + complete( + publication: AgentProfilePublicationBindingV1, + ): Promise; + abort(reason?: unknown): void; +} + +export interface AgentProfileProducerV1 { + /** Fence one immutable profile before the legacy publication begins. */ + prepare( + prepared: PreparedAgentProfileV1, + ): Promise; +} diff --git a/packages/agent/src/system-records/agent-profile-producer-artifacts-v1-internal.ts b/packages/agent/src/system-records/agent-profile-producer-artifacts-v1-internal.ts new file mode 100644 index 0000000000..81170452dd --- /dev/null +++ b/packages/agent/src/system-records/agent-profile-producer-artifacts-v1-internal.ts @@ -0,0 +1,13 @@ +import type { AgentProfileProducerPublicationArtifactsV1 } from './agent-profile-producer-api-v1.js'; +import type { SystemRecordArtifactV1 } from './artifact-v1.js'; + +export function flattenAgentProfileProducerPublicationArtifactsV1( + artifacts: AgentProfileProducerPublicationArtifactsV1, +): readonly SystemRecordArtifactV1[] { + return Object.freeze([ + artifacts.head, + artifacts.bundle, + artifacts.ownedSubjectTable, + ...artifacts.inventoryObjects, + ]); +} diff --git a/packages/agent/src/system-records/agent-profile-producer-commit-v1.ts b/packages/agent/src/system-records/agent-profile-producer-commit-v1.ts new file mode 100644 index 0000000000..6d7b93879d --- /dev/null +++ b/packages/agent/src/system-records/agent-profile-producer-commit-v1.ts @@ -0,0 +1,71 @@ +import type { + AgentProfileProducerPublicationStoreV1, + AgentProfileProducerPublicationV1, + CreateAgentProfileProducerOptionsV1, +} from './agent-profile-producer-api-v1.js'; +import type { AgentProfileProductionInventoryV1 } from './agent-profile-producer-inventory-v1.js'; +import type { AgentProfileProductionPreparationV1 } from './agent-profile-producer-preparation-v1.js'; +import type { SignedAgentProfileProductionV1 } from './agent-profile-producer-signing-v1.js'; + +interface AgentProfileProducerCommitContextV1 { + readonly store: Pick; + readonly install: CreateAgentProfileProducerOptionsV1['install']; +} + +type AgentProfileProducerCommitPreparationV1 = Pick< + AgentProfileProductionPreparationV1, + | 'snapshot' + | 'head' + | 'headDigest' + | 'projectionBytes' + | 'projectionQuads' + | 'ownedSubjectTable' +>; + +type AgentProfileProducerCommitSigningV1 = Pick< + SignedAgentProfileProductionV1, + 'envelope' +>; + +export async function commitAgentProfileProductionV1( + dependencies: AgentProfileProducerCommitContextV1, + preparation: AgentProfileProducerCommitPreparationV1, + signed: AgentProfileProducerCommitSigningV1, + inventoryPlan: AgentProfileProductionInventoryV1, + signal: AbortSignal, +): Promise { + const commitLease = await dependencies.store.prepareCommit({ + expectedHeadDigest: preparation.snapshot.currentHead?.objectDigest ?? null, + expectedRootDescriptorDigest: preparation.snapshot.inventory?.descriptorDigest ?? null, + publicationArtifacts: inventoryPlan.publicationArtifacts, + inventory: inventoryPlan.inventory, + rootEnvelope: inventoryPlan.rootEnvelope, + }); + let committed = false; + try { + signal.throwIfAborted(); + await dependencies.install({ + head: preparation.head, + envelope: signed.envelope, + canonicalProjectionBytes: preparation.projectionBytes, + projectionQuads: preparation.projectionQuads, + ownedSubjectTable: preparation.ownedSubjectTable, + verifiedAuthoritySummary: inventoryPlan.verifiedAuthoritySummary, + signal, + }); + // Installation is the point of no return: a late abort cannot roll it back, + // so the already-reserved advertisement must commit to keep both views aligned. + await commitLease.commit(); + committed = true; + } finally { + if (!committed) commitLease.abort(); + } + return Object.freeze({ + headDigest: preparation.headDigest, + rootDescriptorDigest: inventoryPlan.inventory.descriptorDigest, + version: preparation.head.version, + authoritySequence: preparation.head.authoritySequence, + inventoryWrites: inventoryPlan.inventoryWrites, + inventoryWriteBytes: inventoryPlan.inventoryWriteBytes, + }); +} diff --git a/packages/agent/src/system-records/agent-profile-producer-inventory-v1.ts b/packages/agent/src/system-records/agent-profile-producer-inventory-v1.ts new file mode 100644 index 0000000000..1db86f7873 --- /dev/null +++ b/packages/agent/src/system-records/agent-profile-producer-inventory-v1.ts @@ -0,0 +1,219 @@ +import { + buildAgentProfileVerificationClosureV1, + buildSystemRecordInventoryTreeV1, + buildSystemRecordProviderSignatureMessageV1, + canonicalizeSystemRecordRootDescriptorObjectV1, + computeSystemRecordStableKeyHashV1, + updateSystemRecordInventoryTreeV1, + verifySignedSystemRecordEnvelopeV1, + verifySignedSystemRecordRootDescriptorEnvelopeV1, + type AgentProfileAuthorityTransitionV1, + type AgentProfileForkResolutionV1, + type AgentProfileHeadObjectV1, + type AgentProfileVerifiedAuthoritySummaryV1, + type Digest32V1, + type NetworkIdV1, + type SignedSystemRecordRootDescriptorEnvelopeV1, + type SystemRecordInventoryRowV1, + type SystemRecordInventoryTreeSnapshotV1, + type SystemRecordObjectKindV1, +} from '@origintrail-official/dkg-core/system-record-v1'; + +import { + type AgentProfileProducerArtifactV1, + type AgentProfileProducerPublicationStoreV1, + type AgentProfileProducerPublicationArtifactsV1, + type SystemRecordPeerSignerV1, +} from './agent-profile-producer-api-v1.js'; +import type { AgentProfileProductionPreparationV1 } from './agent-profile-producer-preparation-v1.js'; +import type { SignedAgentProfileProductionV1 } from './agent-profile-producer-signing-v1.js'; +import { flattenAgentProfileProducerPublicationArtifactsV1 } from './agent-profile-producer-artifacts-v1-internal.js'; +import { systemRecordArtifactKeyV1 } from './artifact-v1.js'; + +interface AgentProfileProducerInventoryContextV1 { + readonly networkId: NetworkIdV1; + readonly peerSigner: SystemRecordPeerSignerV1; + readonly store: Pick; +} + +export interface AgentProfileProductionInventoryV1 { + readonly inventory: SystemRecordInventoryTreeSnapshotV1; + readonly rootEnvelope: SignedSystemRecordRootDescriptorEnvelopeV1; + readonly publicationArtifacts: AgentProfileProducerPublicationArtifactsV1; + readonly verifiedAuthoritySummary: AgentProfileVerifiedAuthoritySummaryV1; + readonly inventoryWrites: number; + readonly inventoryWriteBytes: number; +} + +export async function prepareAgentProfileProductionInventoryV1( + dependencies: AgentProfileProducerInventoryContextV1, + preparation: AgentProfileProductionPreparationV1, + signed: SignedAgentProfileProductionV1, + signal: AbortSignal, +): Promise { + const row: SystemRecordInventoryRowV1 = { + stableKeyHash: computeSystemRecordStableKeyHashV1( + dependencies.networkId, + dependencies.peerSigner.peerId, + ), + peerId: dependencies.peerSigner.peerId, + authoritySequence: preparation.head.authoritySequence, + version: preparation.head.version, + headDigest: preparation.headDigest, + tombstone: false, + quarantined: false, + }; + const inventoryUpdate = preparation.snapshot.inventory === null + ? null + : updateSystemRecordInventoryTreeV1(preparation.snapshot.inventory, { + operation: 'upsert', + row, + }); + const inventory = inventoryUpdate === null + ? buildSystemRecordInventoryTreeV1(dependencies.networkId, [row]) + : applyInventoryUpdate(preparation.snapshot.inventory!, inventoryUpdate); + const inventoryWrites = inventoryUpdate?.writes.length + ?? inventory.objects.size + 1; + const inventoryWriteBytes = inventoryUpdate?.accounting.encodedBytes + ?? [...inventory.objects.values()].reduce( + (sum, object) => sum + object.canonicalBytes.byteLength, + 0, + ) + canonicalizeSystemRecordRootDescriptorObjectV1(inventory.descriptor).byteLength; + const rootSignature = await dependencies.peerSigner.sign( + buildSystemRecordProviderSignatureMessageV1( + inventory.descriptor, + inventory.descriptorDigest, + dependencies.peerSigner.peerId, + ), + ); + signal.throwIfAborted(); + const rootEnvelope: SignedSystemRecordRootDescriptorEnvelopeV1 = { + object: inventory.descriptor, + objectDigest: inventory.descriptorDigest, + providerPeerId: dependencies.peerSigner.peerId, + signatureSuite: 'ed25519-v1', + signature: Buffer.from(rootSignature).toString('base64url'), + }; + if (!await verifySignedSystemRecordRootDescriptorEnvelopeV1( + rootEnvelope, + dependencies.peerSigner.publicKey, + )) { + throw new Error('new profile inventory root signature verification failed'); + } + const publicationArtifacts = publicationArtifactSet({ + signed, + preparation, + inventory, + inventoryUpdate, + }); + const artifacts = flattenAgentProfileProducerPublicationArtifactsV1(publicationArtifacts); + const artifactsByKey = new Map( + artifacts.map((artifact) => [systemRecordArtifactKeyV1(artifact), artifact]), + ); + const verifiedClosure = await buildAgentProfileVerificationClosureV1( + preparation.headDigest, + { + nowMs: preparation.verifierNowMs, + resolve: async ({ objectKind, digest }) => { + const reference = { + objectKind, + objectDigest: digest, + } as const; + const artifact = artifactsByKey.get(systemRecordArtifactKeyV1(reference)) + ?? await dependencies.store.resolveArtifact(reference); + return artifact === undefined + || artifact === null + ? undefined + : { + objectKind: artifact.objectKind, + digest: artifact.objectDigest, + canonicalBytes: Uint8Array.from(artifact.canonicalBytes), + }; + }, + verifyAuthorityEnvelope: (envelope) => verifySignedSystemRecordEnvelopeV1< + AgentProfileHeadObjectV1 | AgentProfileAuthorityTransitionV1 | AgentProfileForkResolutionV1 + >(envelope), + verifyCurrentBundle: (_candidate, canonicalBundleBytes) => + Buffer.from(canonicalBundleBytes).equals(Buffer.from(preparation.bundle)), + }, + ); + signal.throwIfAborted(); + return Object.freeze({ + inventory, + rootEnvelope, + publicationArtifacts, + verifiedAuthoritySummary: verifiedClosure.authoritySummary, + inventoryWrites, + inventoryWriteBytes, + }); +} + +interface PublicationArtifactSetInputV1 { + readonly signed: SignedAgentProfileProductionV1; + readonly preparation: AgentProfileProductionPreparationV1; + readonly inventory: SystemRecordInventoryTreeSnapshotV1; + readonly inventoryUpdate: ReturnType | null; +} + +function publicationArtifactSet( + input: PublicationArtifactSetInputV1, +): AgentProfileProducerPublicationArtifactsV1 { + const inventoryObjects = input.inventoryUpdate === null + ? [...input.inventory.objects.entries()].map(([digest, stored]) => ({ digest, ...stored })) + : input.inventoryUpdate.writes; + return Object.freeze({ + head: freezeArtifact( + 'agent-profile-head', + input.signed.envelope.objectDigest, + input.signed.envelopeBytes, + ), + bundle: freezeArtifact( + 'profile-bundle', + input.preparation.bundleDigest, + input.preparation.bundle, + ), + ownedSubjectTable: freezeArtifact( + 'owned-subject-table', + input.preparation.ownedSubjectTableDigest, + input.preparation.ownedSubjectTableBytes, + ), + inventoryObjects: Object.freeze(inventoryObjects.map((object) => freezeArtifact( + object.objectKind, + object.digest, + object.canonicalBytes, + ))), + }); +} + +function freezeArtifact( + objectKind: Kind, + objectDigest: Digest32V1, + bytes: Uint8Array, +): AgentProfileProducerArtifactV1 { + return Object.freeze({ + objectKind, + objectDigest, + canonicalBytes: Uint8Array.from(bytes), + }); +} + +function applyInventoryUpdate( + previous: SystemRecordInventoryTreeSnapshotV1, + update: ReturnType, +): SystemRecordInventoryTreeSnapshotV1 { + if (!update.changed) return previous; + const objects = new Map(previous.objects); + for (const write of update.writes) { + objects.set(write.digest, Object.freeze({ + objectKind: write.objectKind, + object: write.object, + canonicalBytes: Uint8Array.from(write.canonicalBytes), + })); + } + return Object.freeze({ + networkId: previous.networkId, + descriptor: update.descriptor, + descriptorDigest: update.descriptorDigest, + objects, + }); +} diff --git a/packages/agent/src/system-records/agent-profile-producer-preparation-v1.ts b/packages/agent/src/system-records/agent-profile-producer-preparation-v1.ts new file mode 100644 index 0000000000..a4b5ed5152 --- /dev/null +++ b/packages/agent/src/system-records/agent-profile-producer-preparation-v1.ts @@ -0,0 +1,386 @@ +import { + SENTINEL_NO_PRIVATE_V10, + V10MerkleTree, + assertCanonicalDecimalU64, + assertCanonicalDigest, + assertCanonicalEvmAddress, + canonicalizeCanonicalGraphScopedAuthorSealV1, + encodeCanonicalCgSharedPublicRootProjectionV1, + encodeOpaqueKaBundleV1, + keccak256, + parseDeterministicKnowledgeAssetUal, + tripleContentV10, + type CanonicalGraphScopedAuthorSealV1, + type CatalogSealDeploymentProfileV1, +} from '@origintrail-official/dkg-core'; +import { + SYSTEM_RECORD_DIGEST_DOMAINS_V1, + SYSTEM_RECORD_MAX_CLOCK_SKEW_MS, + SYSTEM_RECORD_OBJECT_CAPS_V1, + assertAgentProfileProjectionIdentityV1, + assertAgentProfileProjectionSchemaV1, + assertCanonicalRfc3339SecondsV1, + canonicalizeOwnedSubjectTableObjectV1, + computeAgentProfileHeadObjectDigestV1, + computeOwnedSubjectTableDigestV1, + digestSystemRecordBytesV1, + verifySignedSystemRecordEnvelopeV1, + type AgentProfileActiveHeadObjectV1, + type CanonicalRfc3339SecondsV1, + type Digest32V1, + type NetworkIdV1, + type OwnedSubjectTableObjectV1, +} from '@origintrail-official/dkg-core/system-record-v1'; +import type { Quad } from '@origintrail-official/dkg-storage'; + +import type { PreparedAgentProfileV1 } from '../profile.js'; +import { assertRecoverableGraphScopedAuthorAttestationV1 } from '../rfc64/recoverable-author-attestation-v1.js'; +import type { + AgentProfileProducerPublicationStoreV1, + AgentProfilePublicationBindingV1, + CreateAgentProfileProducerOptionsV1, + SystemRecordPeerSignerV1, +} from './agent-profile-producer-api-v1.js'; + +const UTF8 = new TextEncoder(); +interface AgentProfileProducerPreparationContextV1 { + readonly networkId: NetworkIdV1; + readonly publicationDeployment: Readonly; + readonly peerId: string; + readonly peerPublicKey: SystemRecordPeerSignerV1['publicKey']; + readonly evmIssuer: string; + readonly nowMs: CreateAgentProfileProducerOptionsV1['nowMs']; + readonly store: Pick; +} + +export interface PreparedProfileProjectionSnapshotV1 { + readonly preparedSnapshot: PreparedAgentProfileV1; + readonly projectionQuads: readonly Readonly[]; + readonly ownedSubjectTable: OwnedSubjectTableObjectV1; +} + +export interface AgentProfileProductionPreparationV1 { + readonly snapshot: ReturnType; + readonly verifierNowMs: number; + readonly projectionQuads: readonly Readonly[]; + readonly projectionBytes: Uint8Array; + readonly ownedSubjectTable: OwnedSubjectTableObjectV1; + readonly ownedSubjectTableBytes: Uint8Array; + readonly ownedSubjectTableDigest: Digest32V1; + readonly bundle: Uint8Array; + readonly bundleDigest: Digest32V1; + readonly head: AgentProfileActiveHeadObjectV1; + readonly headDigest: Digest32V1; +} + +export async function prepareAgentProfileProductionV1( + dependencies: AgentProfileProducerPreparationContextV1, + input: PreparedProfileProjectionSnapshotV1, + inputPublication: AgentProfilePublicationBindingV1, +): Promise { + const { preparedSnapshot: prepared, projectionQuads, ownedSubjectTable } = input; + const publication = snapshotConfirmedPublicationBindingV1(inputPublication); + const issuedAt = normalizePublicationTimestampV1(publication.issuedAt, 'issuedAt'); + const validUntil = normalizePublicationTimestampV1(publication.validUntil, 'validUntil'); + normalizePublicationTimestampV1( + publication.seal.assertionFinalizedAt, + 'assertionFinalizedAt', + ); + const assertionFinalizedAtMs = Date.parse(publication.seal.assertionFinalizedAt); + const verifierNowMs = producerNowMs(dependencies.nowMs?.() ?? Date.now()); + if (Date.parse(issuedAt) > verifierNowMs + SYSTEM_RECORD_MAX_CLOCK_SKEW_MS) { + throw new Error('agent-profile issuedAt exceeds the future clock-skew bound'); + } + if (Date.parse(issuedAt) < assertionFinalizedAtMs) { + throw new Error('agent-profile issuedAt predates assertion finalization'); + } + if (Date.parse(validUntil) <= Date.parse(issuedAt)) { + throw new Error('agent-profile validUntil must be later than issuedAt'); + } + if (Date.parse(validUntil) <= verifierNowMs) { + throw new Error('agent-profile validUntil is already expired'); + } + const evmIssuer = dependencies.evmIssuer; + assertCanonicalEvmAddress(evmIssuer, 'profile EVM issuer'); + const snapshot = dependencies.store.snapshot(); + const previous = snapshot.currentHead; + if (previous !== null + && (previous.object.networkId !== dependencies.networkId + || previous.object.peerId !== dependencies.peerId)) { + throw new Error('stored profile head belongs to a different stable record'); + } + if (previous !== null && !await verifySignedSystemRecordEnvelopeV1(previous)) { + throw new Error('stored profile head signature verification failed'); + } + if (previous?.object.state === 'tombstone') { + throw new Error('a tombstoned profile requires an explicit authority transition'); + } + if (previous !== null && previous.object.evmIssuer !== evmIssuer) { + throw new Error('agent-profile authority transition must be authored explicitly'); + } + const projectionBytes = encodeCanonicalCgSharedPublicRootProjectionV1(projectionQuads); + const contentDigest = computeProjectionContentDigest(projectionQuads); + if (contentDigest !== publication.seal.assertionMerkleRoot + || publication.seal.authorAddress !== evmIssuer + || publication.seal.publicTripleCount !== String(projectionQuads.length) + || publication.seal.privateTripleCount !== '0' + || publication.seal.privateMerkleRoot !== null) { + throw new Error('profile publication seal does not bind the exact public projection'); + } + assertPublicationLaneV1( + publication.seal, + dependencies.networkId, + dependencies.publicationDeployment, + evmIssuer, + ); + assertRecoverableGraphScopedAuthorAttestationV1(publication.seal); + const ownedSubjectTableBytes = canonicalizeOwnedSubjectTableObjectV1( + prepared.rootEntity, + ownedSubjectTable, + ); + const ownedSubjectTableDigest = computeOwnedSubjectTableDigestV1( + prepared.rootEntity, + ownedSubjectTable, + ); + const sealBytes = UTF8.encode(canonicalizeCanonicalGraphScopedAuthorSealV1(publication.seal)); + const bundle = encodeOpaqueKaBundleV1(projectionBytes, sealBytes).bundleBytes; + if (bundle.byteLength > SYSTEM_RECORD_OBJECT_CAPS_V1['profile-bundle']) { + throw new Error('profile bundle exceeds the system-record V1 cap'); + } + const bundleDigest = digestSystemRecordBytesV1( + SYSTEM_RECORD_DIGEST_DOMAINS_V1.profileBundle, + bundle, + ); + const authoritySequence = previous?.object.authoritySequence ?? '0'; + const version = previous === null + ? '0' + : (BigInt(previous.object.version) + 1n).toString(); + const ownedSubjectCount = String(ownedSubjectTable.length); + const projectionByteCount = String(projectionBytes.byteLength); + const projectionQuadCount = String(projectionQuads.length); + assertCanonicalDecimalU64(authoritySequence, 'profile authoritySequence'); + assertCanonicalDecimalU64(version, 'profile version'); + assertCanonicalDecimalU64(ownedSubjectCount, 'profile ownedSubjectCount'); + assertCanonicalDecimalU64(projectionByteCount, 'profile projectionBytes'); + assertCanonicalDecimalU64(projectionQuadCount, 'profile projectionQuads'); + if (previous !== null + && (previous.object.rootSubject !== prepared.rootEntity + || previous.object.projectionSchemaDigest !== publication.projectionSchemaDigest)) { + throw new Error('ordinary profile update changed its root or projection schema'); + } + const head: AgentProfileActiveHeadObjectV1 = { + objectType: 'agent-profile-head', + kind: 'agents', + state: 'active', + networkId: dependencies.networkId, + peerId: dependencies.peerId, + peerPublicKey: dependencies.peerPublicKey, + authoritySequence, + version, + ...(previous === null ? {} : { previousHeadDigest: previous.objectDigest }), + ...(previous?.object.acceptedTransitionDigest === undefined ? {} : { + acceptedTransitionDigest: previous.object.acceptedTransitionDigest, + }), + evmIssuer, + rootSubject: prepared.rootEntity, + projectionSchemaDigest: publication.projectionSchemaDigest, + issuedAt, + ownedSubjectTableDigest, + ownedSubjectCount, + projectionBytes: projectionByteCount, + projectionQuads: projectionQuadCount, + validUntil, + assertionCoordinate: publication.assertionCoordinate, + graphScopedAuthorSeal: publication.seal, + contentDigest, + bundleDigest, + }; + const headDigest = computeAgentProfileHeadObjectDigestV1(head); + return Object.freeze({ + snapshot, + verifierNowMs, + projectionQuads, + projectionBytes, + ownedSubjectTable, + ownedSubjectTableBytes, + ownedSubjectTableDigest, + bundle, + bundleDigest, + head, + headDigest, + }); +} + +export function snapshotAgentProfileProductionInputV1( + dependencies: Pick< + AgentProfileProducerPreparationContextV1, + 'peerId' | 'peerPublicKey' | 'evmIssuer' + >, + prepared: PreparedAgentProfileV1, +): PreparedProfileProjectionSnapshotV1 { + const preparedSnapshot = snapshotPreparedProfileV1(prepared); + const projected = preparedSnapshot.projectionQuads + .map((quad) => Object.freeze({ ...quad })); + projected.sort(compareQuads); + for (let index = 1; index < projected.length; index += 1) { + if (compareQuads(projected[index - 1]!, projected[index]!) === 0) { + throw new Error('profile projection must be canonical and duplicate-free'); + } + } + let ownedSubjectTable: OwnedSubjectTableObjectV1; + try { + ownedSubjectTable = ownedSubjects(preparedSnapshot.rootEntity, projected); + assertAgentProfileProjectionSchemaV1(preparedSnapshot.rootEntity, ownedSubjectTable, projected); + } catch (cause) { + throw new Error('profile projection is outside schema V1', { cause }); + } + assertAdvertisedAgentProfileIdentityV1( + preparedSnapshot.rootEntity, + projected, + { peerId: dependencies.peerId, publicKey: dependencies.peerPublicKey }, + dependencies.evmIssuer, + ); + return Object.freeze({ + preparedSnapshot, + projectionQuads: Object.freeze(projected), + ownedSubjectTable, + }); +} + +function snapshotPreparedProfileV1( + prepared: PreparedAgentProfileV1, +): PreparedAgentProfileV1 { + if (!Array.isArray(prepared.publicationQuads) + || !Array.isArray(prepared.projectionQuads) + || typeof prepared.rootEntity !== 'string' + || typeof prepared.lastSeen !== 'string') { + throw new TypeError('prepared profile has an invalid structural shape'); + } + return Object.freeze({ + publicationQuads: Object.freeze( + prepared.publicationQuads.map((quad) => Object.freeze({ ...quad })), + ), + projectionQuads: Object.freeze( + prepared.projectionQuads.map((quad) => Object.freeze({ ...quad })), + ), + rootEntity: prepared.rootEntity, + lastSeen: prepared.lastSeen, + }); +} + +function assertAdvertisedAgentProfileIdentityV1( + rootSubject: string, + quads: readonly Readonly[], + peerSigner: Pick, + evmAddress: string, +): void { + assertCanonicalEvmAddress(evmAddress, 'profile EVM issuer'); + assertAgentProfileProjectionIdentityV1({ + rootSubject, + peerId: peerSigner.peerId, + peerPublicKey: peerSigner.publicKey, + evmIssuer: evmAddress, + }, quads); +} + +type ConfirmedAgentProfilePublicationBindingV1 = Readonly< + Omit & { + readonly publicationStatus: 'confirmed'; + } +>; + +function snapshotConfirmedPublicationBindingV1( + publication: AgentProfilePublicationBindingV1, +): ConfirmedAgentProfilePublicationBindingV1 { + if (publication.publicationStatus !== 'confirmed') { + throw new Error('agent-profile system record requires a confirmed publication'); + } + return Object.freeze({ + publicationStatus: 'confirmed', + assertionCoordinate: publication.assertionCoordinate, + seal: Object.freeze({ ...publication.seal }), + issuedAt: publication.issuedAt, + validUntil: publication.validUntil, + projectionSchemaDigest: publication.projectionSchemaDigest, + }); +} + +function producerNowMs(value: number): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error('agent-profile producer clock returned an invalid value'); + } + return value; +} + +function assertPublicationLaneV1( + seal: Readonly, + networkId: NetworkIdV1, + deployment: Readonly, + evmIssuer: string, +): void { + const ual = parseDeterministicKnowledgeAssetUal(seal.kaUal); + if (deployment.networkId !== networkId + || ual.ual !== seal.kaUal + || ual.chainId !== networkId + || ual.agentAddress !== evmIssuer + || seal.assertedAtChainId !== deployment.assertedAtChainId + || seal.assertedAtKav10Address !== deployment.assertedAtKav10Address) { + throw new Error('profile publication seal belongs to a different network or deployment'); + } +} + +function ownedSubjects( + rootSubject: string, + quads: readonly Readonly[], +): OwnedSubjectTableObjectV1 { + const subjects = [...new Set(quads.map((quad) => quad.subject))].sort(compareUtf8); + if (!subjects.includes(rootSubject)) { + throw new Error('profile projection does not contain its canonical root subject'); + } + return Object.freeze(subjects); +} + +function computeProjectionContentDigest( + quads: readonly Readonly[], +): Digest32V1 { + const leaves = quads.map((quad) => keccak256( + tripleContentV10(quad.subject, quad.predicate, quad.object), + )); + const root = V10MerkleTree.computeKARoot( + new V10MerkleTree(leaves).root, + SENTINEL_NO_PRIVATE_V10, + ); + const digest = `0x${Buffer.from(root).toString('hex')}`; + assertCanonicalDigest(digest, 'profile content digest'); + return digest; +} + +function compareQuads(left: Readonly, right: Readonly): number { + return Buffer.compare( + tripleContentV10(left.subject, left.predicate, left.object), + tripleContentV10(right.subject, right.predicate, right.object), + ); +} + +function compareUtf8(left: string, right: string): number { + return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')); +} + +function normalizePublicationTimestampV1( + value: string, + label: string, +): CanonicalRfc3339SecondsV1 { + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value)) { + throw new Error(`${label} must be an RFC3339 UTC timestamp`); + } + const millis = Date.parse(value); + if (!Number.isFinite(millis)) throw new Error(`${label} must be a valid RFC3339 UTC timestamp`); + const canonical = new Date(Math.floor(millis / 1_000) * 1_000) + .toISOString() + .replace('.000Z', 'Z'); + if (canonical !== `${value.slice(0, 19)}Z`) { + throw new Error(`${label} must be a calendar-valid RFC3339 UTC timestamp`); + } + assertCanonicalRfc3339SecondsV1(canonical, label); + return canonical; +} diff --git a/packages/agent/src/system-records/agent-profile-producer-signing-v1.ts b/packages/agent/src/system-records/agent-profile-producer-signing-v1.ts new file mode 100644 index 0000000000..09541d9d8f --- /dev/null +++ b/packages/agent/src/system-records/agent-profile-producer-signing-v1.ts @@ -0,0 +1,76 @@ +import { + buildSystemRecordSignatureMessageV1, + canonicalizeSignedSystemRecordEnvelopeV1, + verifySignedSystemRecordEnvelopeV1, + type SignedAgentProfileHeadEnvelopeV1, +} from '@origintrail-official/dkg-core/system-record-v1'; + +import type { EvmPersonalMessageSignerV1 } from '../evm-message-signer-v1.js'; +import type { SystemRecordPeerSignerV1 } from './agent-profile-producer-api-v1.js'; +import type { AgentProfileProductionPreparationV1 } from './agent-profile-producer-preparation-v1.js'; + +interface AgentProfileProducerSigningContextV1 { + readonly peerSigner: SystemRecordPeerSignerV1; + readonly evmSigner: EvmPersonalMessageSignerV1; +} + +export interface SignedAgentProfileProductionV1 { + readonly envelope: SignedAgentProfileHeadEnvelopeV1; + readonly envelopeBytes: Uint8Array; +} + +type AgentProfileProducerSigningInputV1 = Pick< + AgentProfileProductionPreparationV1, + 'head' | 'headDigest' +>; + +export async function signAgentProfileProductionV1( + dependencies: AgentProfileProducerSigningContextV1, + preparation: AgentProfileProducerSigningInputV1, + signal: AbortSignal, +): Promise { + const [peerSignature, evmSignature] = await Promise.all([ + dependencies.peerSigner.sign( + buildSystemRecordSignatureMessageV1( + preparation.head, + preparation.headDigest, + 'peer', + ), + ), + dependencies.evmSigner.signMessage( + buildSystemRecordSignatureMessageV1( + preparation.head, + preparation.headDigest, + 'current-evm', + ), + ), + ]); + signal.throwIfAborted(); + const envelope: SignedAgentProfileHeadEnvelopeV1 = { + object: preparation.head, + objectDigest: preparation.headDigest, + signatures: Object.freeze([ + Object.freeze({ + role: 'peer', + suite: 'ed25519-v1', + signer: dependencies.peerSigner.peerId, + evidence: Object.freeze({ kind: 'none' }), + signature: Buffer.from(peerSignature).toString('base64url'), + }), + Object.freeze({ + role: 'current-evm', + suite: 'eip191-personal-sign-digest-v1', + signer: dependencies.evmSigner.address, + evidence: Object.freeze({ kind: 'none' }), + signature: evmSignature, + }), + ]), + }; + if (!await verifySignedSystemRecordEnvelopeV1(envelope)) { + throw new Error('new profile head signature verification failed'); + } + return Object.freeze({ + envelope, + envelopeBytes: canonicalizeSignedSystemRecordEnvelopeV1(envelope), + }); +} diff --git a/packages/agent/src/system-records/agent-profile-producer-v1.ts b/packages/agent/src/system-records/agent-profile-producer-v1.ts index e7e9e01686..9b77a73e9f 100644 --- a/packages/agent/src/system-records/agent-profile-producer-v1.ts +++ b/packages/agent/src/system-records/agent-profile-producer-v1.ts @@ -1,190 +1,26 @@ // SPDX-License-Identifier: Apache-2.0 -import { - SENTINEL_NO_PRIVATE_V10, - V10MerkleTree, - canonicalizeCanonicalGraphScopedAuthorSealV1, - encodeCanonicalCgSharedPublicRootProjectionV1, - encodeOpaqueKaBundleV1, - keccak256, - parseDeterministicKnowledgeAssetUal, - tripleContentV10, - assertCanonicalDecimalU64, - assertCanonicalDigest, - assertCanonicalEvmAddress, - type AssertionCoordinateV1, - type CanonicalGraphScopedAuthorSealV1, - type CatalogSealDeploymentProfileV1, -} from '@origintrail-official/dkg-core'; -import { - SYSTEM_RECORD_DIGEST_DOMAINS_V1, - SYSTEM_RECORD_MAX_CLOCK_SKEW_MS, - SYSTEM_RECORD_OBJECT_CAPS_V1, - assertAgentProfileProjectionIdentityV1, - assertAgentProfileProjectionSchemaV1, - assertCanonicalRfc3339SecondsV1, - buildSystemRecordProviderSignatureMessageV1, - buildSystemRecordSignatureMessageV1, - buildSystemRecordInventoryTreeV1, - buildAgentProfileVerificationClosureV1, - canonicalizeOwnedSubjectTableObjectV1, - canonicalizeSignedSystemRecordEnvelopeV1, - canonicalizeSystemRecordRootDescriptorObjectV1, - computeAgentProfileHeadObjectDigestV1, - computeOwnedSubjectTableDigestV1, - computeSystemRecordStableKeyHashV1, - digestSystemRecordBytesV1, - updateSystemRecordInventoryTreeV1, - verifySignedSystemRecordEnvelopeV1, - verifySignedSystemRecordRootDescriptorEnvelopeV1, - type AgentProfileActiveHeadObjectV1, - type AgentProfileAuthorityTransitionV1, - type AgentProfileForkResolutionV1, - type AgentProfileHeadObjectV1, - type AgentProfileVerifiedAuthoritySummaryV1, - type CanonicalRfc3339SecondsV1, - type Digest32V1, - type NetworkIdV1, - type OwnedSubjectTableObjectV1, - type SignedAgentProfileHeadEnvelopeV1, - type SignedSystemRecordRootDescriptorEnvelopeV1, - type SystemRecordInventoryRowV1, - type SystemRecordInventoryTreeSnapshotV1, - type SystemRecordObjectKindV1, - type SystemRecordPeerPublicKeyV1, -} from '@origintrail-official/dkg-core/system-record-v1'; -import type { Quad } from '@origintrail-official/dkg-storage'; - -import type { EvmPersonalMessageSignerV1 } from '../evm-message-signer-v1.js'; import type { PreparedAgentProfileV1 } from '../profile.js'; -import { assertRecoverableGraphScopedAuthorAttestationV1 } from '../rfc64/recoverable-author-attestation-v1.js'; +import { commitAgentProfileProductionV1 } from './agent-profile-producer-commit-v1.js'; +import type { + AgentProfileProducerLeaseV1, + AgentProfileProducerPublicationV1, + AgentProfileProducerV1, + AgentProfilePublicationBindingV1, + CreateAgentProfileProducerOptionsV1, +} from './agent-profile-producer-api-v1.js'; +import { prepareAgentProfileProductionInventoryV1 } from './agent-profile-producer-inventory-v1.js'; import { - systemRecordArtifactKeyV1, - type SystemRecordArtifactV1, -} from './artifact-v1.js'; - -const UTF8 = new TextEncoder(); - -export interface SystemRecordPeerSignerV1 { - readonly peerId: string; - readonly publicKey: SystemRecordPeerPublicKeyV1; - sign(message: Uint8Array): Promise; -} - -export type AgentProfilePublicationStatusV1 = 'tentative' | 'confirmed' | 'failed'; - -/** Untrusted legacy-publication result accepted at the producer boundary. */ -export interface AgentProfilePublicationBindingV1 { - readonly publicationStatus: AgentProfilePublicationStatusV1; - readonly assertionCoordinate: AssertionCoordinateV1; - readonly seal: Readonly; - readonly issuedAt: string; - readonly validUntil: string; - readonly projectionSchemaDigest: Digest32V1; -} - -export interface AgentProfileProducerInstallInputV1 { - readonly head: AgentProfileActiveHeadObjectV1; - readonly envelope: SignedAgentProfileHeadEnvelopeV1; - readonly canonicalProjectionBytes: Uint8Array; - readonly projectionQuads: readonly Readonly[]; - readonly ownedSubjectTable: OwnedSubjectTableObjectV1; - readonly verifiedAuthoritySummary: AgentProfileVerifiedAuthoritySummaryV1; - readonly signal: AbortSignal; -} - -export interface AgentProfileProducerPublicationV1 { - readonly headDigest: Digest32V1; - readonly rootDescriptorDigest: Digest32V1; - readonly version: string; - readonly authoritySequence: string; - readonly inventoryWrites: number; - readonly inventoryWriteBytes: number; -} - -export interface AgentProfileProducerPublicationCommitV1 { - /** Snapshot preconditions reserved before materialization begins. */ - readonly expectedHeadDigest: Digest32V1 | null; - readonly expectedRootDescriptorDigest: Digest32V1 | null; - readonly publicationArtifacts: AgentProfileProducerPublicationArtifactsV1; - readonly inventory: SystemRecordInventoryTreeSnapshotV1; - readonly rootEnvelope: SignedSystemRecordRootDescriptorEnvelopeV1; -} - -type AgentProfileProducerArtifactV1 = Readonly< - Omit & { objectKind: Kind } ->; - -export interface AgentProfileProducerPublicationArtifactsV1 { - readonly head: AgentProfileProducerArtifactV1<'agent-profile-head'>; - readonly bundle: AgentProfileProducerArtifactV1<'profile-bundle'>; - readonly ownedSubjectTable: AgentProfileProducerArtifactV1<'owned-subject-table'>; - readonly inventoryObjects: readonly AgentProfileProducerArtifactV1< - 'inventory-internal' | 'inventory-leaf' - >[]; -} - -export function flattenAgentProfileProducerPublicationArtifactsV1( - artifacts: AgentProfileProducerPublicationArtifactsV1, -): readonly SystemRecordArtifactV1[] { - return Object.freeze([ - artifacts.head, - artifacts.bundle, - artifacts.ownedSubjectTable, - ...artifacts.inventoryObjects, - ]); -} - -export interface AgentProfileProducerPublicationCommitLeaseV1 { - commit(): void | Promise; - abort(): void; -} - -export interface AgentProfileProducerPublicationStoreV1 { - snapshot(): Readonly<{ - inventory: SystemRecordInventoryTreeSnapshotV1 | null; - currentHead: SignedAgentProfileHeadEnvelopeV1 | null; - }>; - /** Resolve retained authority history by content address, without wire semantics. */ - resolveArtifact( - reference: Pick, - ): SystemRecordArtifactV1 | null | Promise; - /** Atomically verify and reserve the expected snapshot until commit or abort. */ - prepareCommit( - input: AgentProfileProducerPublicationCommitV1, - ): AgentProfileProducerPublicationCommitLeaseV1 | Promise; -} + prepareAgentProfileProductionV1, + snapshotAgentProfileProductionInputV1, + type PreparedProfileProjectionSnapshotV1, +} from './agent-profile-producer-preparation-v1.js'; +import { signAgentProfileProductionV1 } from './agent-profile-producer-signing-v1.js'; -export interface CreateAgentProfileProducerOptionsV1 { - readonly networkId: NetworkIdV1; - /** Locally pinned VM publication lane; never derived from an untrusted seal. */ - readonly publicationDeployment: Readonly; - readonly peerSigner: SystemRecordPeerSignerV1; - readonly evmSigner: EvmPersonalMessageSignerV1; - readonly store: AgentProfileProducerPublicationStoreV1; - /** Independent verifier clock; publication timestamps are untrusted input. */ - readonly nowMs?: () => number; - /** Storage-runtime bridge: fence before publish; successful install commits advertisement. */ - readonly fence: ( - prepared: PreparedAgentProfileV1, - signal: AbortSignal, - ) => void | Promise; - readonly install: (input: AgentProfileProducerInstallInputV1) => void | Promise; -} - -export interface AgentProfileProducerLeaseV1 { - complete( - publication: AgentProfilePublicationBindingV1, - ): Promise; - abort(reason?: unknown): void; -} - -export interface AgentProfileProducerV1 { - /** Fence one immutable profile before the legacy publication begins. */ - prepare( - prepared: PreparedAgentProfileV1, - ): Promise; -} +export * from './agent-profile-producer-api-v1.js'; +export { + flattenAgentProfileProducerPublicationArtifactsV1, +} from './agent-profile-producer-artifacts-v1-internal.js'; /** * Author one local profile record. No protocol, timer, queue, or independent @@ -193,305 +29,61 @@ export interface AgentProfileProducerV1 { export function createAgentProfileProducerV1( options: CreateAgentProfileProducerOptionsV1, ): AgentProfileProducerV1 { + const context = Object.freeze({ + networkId: options.networkId, + publicationDeployment: options.publicationDeployment, + peerId: options.peerSigner.peerId, + peerPublicKey: options.peerSigner.publicKey, + evmIssuer: options.evmSigner.address, + nowMs: options.nowMs, + store: options.store, + peerSigner: options.peerSigner, + evmSigner: options.evmSigner, + install: options.install, + }); let active = false; const completePrepared = async ( - prepared: PreparedAgentProfileV1, - projectionQuads: readonly Readonly[], - inputPublication: AgentProfilePublicationBindingV1, + input: PreparedProfileProjectionSnapshotV1, + publication: AgentProfilePublicationBindingV1, signal: AbortSignal, ): Promise => { signal.throwIfAborted(); - const publication = snapshotConfirmedPublicationBindingV1(inputPublication); - const issuedAt = normalizePublicationTimestampV1(publication.issuedAt, 'issuedAt'); - const validUntil = normalizePublicationTimestampV1(publication.validUntil, 'validUntil'); - normalizePublicationTimestampV1( - publication.seal.assertionFinalizedAt, - 'assertionFinalizedAt', - ); - const assertionFinalizedAtMs = Date.parse(publication.seal.assertionFinalizedAt); - const verifierNowMs = producerNowMs(options.nowMs?.() ?? Date.now()); - if (Date.parse(issuedAt) > verifierNowMs + SYSTEM_RECORD_MAX_CLOCK_SKEW_MS) { - throw new Error('agent-profile issuedAt exceeds the future clock-skew bound'); - } - if (Date.parse(issuedAt) < assertionFinalizedAtMs) { - throw new Error('agent-profile issuedAt predates assertion finalization'); - } - if (Date.parse(validUntil) <= Date.parse(issuedAt)) { - throw new Error('agent-profile validUntil must be later than issuedAt'); - } - if (Date.parse(validUntil) <= verifierNowMs) { - throw new Error('agent-profile validUntil is already expired'); - } - const evmIssuer = options.evmSigner.address; - assertCanonicalEvmAddress(evmIssuer, 'profile EVM issuer'); - const snapshot = options.store.snapshot(); - const previous = snapshot.currentHead; - if (previous !== null - && (previous.object.networkId !== options.networkId - || previous.object.peerId !== options.peerSigner.peerId)) { - throw new Error('stored profile head belongs to a different stable record'); - } - if (previous !== null && !await verifySignedSystemRecordEnvelopeV1(previous)) { - throw new Error('stored profile head signature verification failed'); - } - if (previous?.object.state === 'tombstone') { - throw new Error('a tombstoned profile requires an explicit authority transition'); - } - if (previous !== null && previous.object.evmIssuer !== evmIssuer) { - throw new Error('agent-profile authority transition must be authored explicitly'); - } - const projectionBytes = encodeCanonicalCgSharedPublicRootProjectionV1(projectionQuads); - const contentDigest = computeProjectionContentDigest(projectionQuads); - if (contentDigest !== publication.seal.assertionMerkleRoot - || publication.seal.authorAddress !== evmIssuer - || publication.seal.publicTripleCount !== String(projectionQuads.length) - || publication.seal.privateTripleCount !== '0' - || publication.seal.privateMerkleRoot !== null) { - throw new Error('profile publication seal does not bind the exact public projection'); - } - assertPublicationLaneV1( - publication.seal, - options.networkId, - options.publicationDeployment, - evmIssuer, - ); - assertRecoverableGraphScopedAuthorAttestationV1(publication.seal); - const ownedSubjectTable = ownedSubjects(prepared.rootEntity, projectionQuads); - const ownedSubjectTableBytes = canonicalizeOwnedSubjectTableObjectV1( - prepared.rootEntity, - ownedSubjectTable, + const preparation = await prepareAgentProfileProductionV1( + context, + input, + publication, ); - const ownedSubjectTableDigest = computeOwnedSubjectTableDigestV1( - prepared.rootEntity, - ownedSubjectTable, + const signed = await signAgentProfileProductionV1( + context, + preparation, + signal, ); - const sealBytes = UTF8.encode(canonicalizeCanonicalGraphScopedAuthorSealV1(publication.seal)); - const bundle = encodeOpaqueKaBundleV1(projectionBytes, sealBytes).bundleBytes; - if (bundle.byteLength > SYSTEM_RECORD_OBJECT_CAPS_V1['profile-bundle']) { - throw new Error('profile bundle exceeds the system-record V1 cap'); - } - const bundleDigest = digestSystemRecordBytesV1( - SYSTEM_RECORD_DIGEST_DOMAINS_V1.profileBundle, - bundle, - ); - const authoritySequence = previous?.object.authoritySequence ?? '0'; - const version = previous === null - ? '0' - : (BigInt(previous.object.version) + 1n).toString(); - const ownedSubjectCount = String(ownedSubjectTable.length); - const projectionByteCount = String(projectionBytes.byteLength); - const projectionQuadCount = String(projectionQuads.length); - assertCanonicalDecimalU64(authoritySequence, 'profile authoritySequence'); - assertCanonicalDecimalU64(version, 'profile version'); - assertCanonicalDecimalU64(ownedSubjectCount, 'profile ownedSubjectCount'); - assertCanonicalDecimalU64(projectionByteCount, 'profile projectionBytes'); - assertCanonicalDecimalU64(projectionQuadCount, 'profile projectionQuads'); - if (previous !== null - && (previous.object.rootSubject !== prepared.rootEntity - || previous.object.projectionSchemaDigest !== publication.projectionSchemaDigest)) { - throw new Error('ordinary profile update changed its root or projection schema'); - } - const head: AgentProfileActiveHeadObjectV1 = { - objectType: 'agent-profile-head', - kind: 'agents', - state: 'active', - networkId: options.networkId, - peerId: options.peerSigner.peerId, - peerPublicKey: options.peerSigner.publicKey, - authoritySequence, - version, - ...(previous === null ? {} : { previousHeadDigest: previous.objectDigest }), - ...(previous?.object.acceptedTransitionDigest === undefined ? {} : { - acceptedTransitionDigest: previous.object.acceptedTransitionDigest, - }), - evmIssuer, - rootSubject: prepared.rootEntity, - projectionSchemaDigest: publication.projectionSchemaDigest, - issuedAt, - ownedSubjectTableDigest, - ownedSubjectCount, - projectionBytes: projectionByteCount, - projectionQuads: projectionQuadCount, - validUntil, - assertionCoordinate: publication.assertionCoordinate, - graphScopedAuthorSeal: publication.seal, - contentDigest, - bundleDigest, - }; - const headDigest = computeAgentProfileHeadObjectDigestV1(head); - const [peerSignature, evmSignature] = await Promise.all([ - options.peerSigner.sign( - buildSystemRecordSignatureMessageV1(head, headDigest, 'peer'), - ), - options.evmSigner.signMessage( - buildSystemRecordSignatureMessageV1(head, headDigest, 'current-evm'), - ), - ]); - signal.throwIfAborted(); - const envelope: SignedAgentProfileHeadEnvelopeV1 = { - object: head, - objectDigest: headDigest, - signatures: Object.freeze([ - Object.freeze({ - role: 'peer', - suite: 'ed25519-v1', - signer: options.peerSigner.peerId, - evidence: Object.freeze({ kind: 'none' }), - signature: Buffer.from(peerSignature).toString('base64url'), - }), - Object.freeze({ - role: 'current-evm', - suite: 'eip191-personal-sign-digest-v1', - signer: options.evmSigner.address, - evidence: Object.freeze({ kind: 'none' }), - signature: evmSignature, - }), - ]), - }; - if (!await verifySignedSystemRecordEnvelopeV1(envelope)) { - throw new Error('new profile head signature verification failed'); - } - const envelopeBytes = canonicalizeSignedSystemRecordEnvelopeV1(envelope); - const row: SystemRecordInventoryRowV1 = { - stableKeyHash: computeSystemRecordStableKeyHashV1( - options.networkId, - options.peerSigner.peerId, - ), - peerId: options.peerSigner.peerId, - authoritySequence: head.authoritySequence, - version: head.version, - headDigest, - tombstone: false, - quarantined: false, - }; - const inventoryUpdate = snapshot.inventory === null - ? null - : updateSystemRecordInventoryTreeV1(snapshot.inventory, { - operation: 'upsert', - row, - }); - const inventory = inventoryUpdate === null - ? buildSystemRecordInventoryTreeV1(options.networkId, [row]) - : applyInventoryUpdate(snapshot.inventory!, inventoryUpdate); - const inventoryWrites = inventoryUpdate?.writes.length - ?? inventory.objects.size + 1; - const inventoryWriteBytes = inventoryUpdate?.accounting.encodedBytes - ?? [...inventory.objects.values()].reduce( - (sum, object) => sum + object.canonicalBytes.byteLength, - 0, - ) + canonicalizeSystemRecordRootDescriptorObjectV1(inventory.descriptor).byteLength; - const rootSignature = await options.peerSigner.sign( - buildSystemRecordProviderSignatureMessageV1( - inventory.descriptor, - inventory.descriptorDigest, - options.peerSigner.peerId, - ), + const inventoryPlan = await prepareAgentProfileProductionInventoryV1( + context, + preparation, + signed, + signal, ); - signal.throwIfAborted(); - const rootEnvelope: SignedSystemRecordRootDescriptorEnvelopeV1 = { - object: inventory.descriptor, - objectDigest: inventory.descriptorDigest, - providerPeerId: options.peerSigner.peerId, - signatureSuite: 'ed25519-v1', - signature: Buffer.from(rootSignature).toString('base64url'), - }; - if (!await verifySignedSystemRecordRootDescriptorEnvelopeV1( - rootEnvelope, - options.peerSigner.publicKey, - )) { - throw new Error('new profile inventory root signature verification failed'); - } - const publicationArtifactSet = publicationArtifacts({ - envelope, - envelopeBytes, - bundle, - bundleDigest, - ownedSubjectTableBytes, - ownedSubjectTableDigest, - inventory, - inventoryUpdate, - }); - const artifacts = flattenAgentProfileProducerPublicationArtifactsV1(publicationArtifactSet); - const artifactsByKey = new Map( - artifacts.map((artifact) => [systemRecordArtifactKeyV1(artifact), artifact]), + return commitAgentProfileProductionV1( + context, + preparation, + signed, + inventoryPlan, + signal, ); - const verifiedClosure = await buildAgentProfileVerificationClosureV1(headDigest, { - nowMs: verifierNowMs, - resolve: async ({ objectKind, digest }) => { - const reference = { - objectKind, - objectDigest: digest, - } as const; - const artifact = artifactsByKey.get(systemRecordArtifactKeyV1(reference)) - ?? await options.store.resolveArtifact(reference); - return artifact === undefined - || artifact === null - ? undefined - : { - objectKind: artifact.objectKind, - digest: artifact.objectDigest, - canonicalBytes: Uint8Array.from(artifact.canonicalBytes), - }; - }, - verifyAuthorityEnvelope: (envelope) => verifySignedSystemRecordEnvelopeV1< - AgentProfileHeadObjectV1 | AgentProfileAuthorityTransitionV1 | AgentProfileForkResolutionV1 - >(envelope), - verifyCurrentBundle: (_candidate, canonicalBundleBytes) => - Buffer.from(canonicalBundleBytes).equals(Buffer.from(bundle)), - }); - signal.throwIfAborted(); - - const commitLease = await options.store.prepareCommit({ - expectedHeadDigest: previous?.objectDigest ?? null, - expectedRootDescriptorDigest: snapshot.inventory?.descriptorDigest ?? null, - publicationArtifacts: publicationArtifactSet, - inventory, - rootEnvelope, - }); - let committed = false; - try { - signal.throwIfAborted(); - await options.install({ - head, - envelope, - canonicalProjectionBytes: projectionBytes, - projectionQuads, - ownedSubjectTable, - verifiedAuthoritySummary: verifiedClosure.authoritySummary, - signal, - }); - // Installation is the point of no return: a late abort cannot roll it back, - // so the already-reserved advertisement must commit to keep both views aligned. - await commitLease.commit(); - committed = true; - } finally { - if (!committed) commitLease.abort(); - } - return Object.freeze({ - headDigest, - rootDescriptorDigest: inventory.descriptorDigest, - version, - authoritySequence, - inventoryWrites, - inventoryWriteBytes, - }); }; return Object.freeze({ async prepare(prepared: PreparedAgentProfileV1): Promise { if (active) throw new Error('agent-profile producer is busy'); - const preparedSnapshot = snapshotPreparedProfileV1(prepared); - const projectionQuads = validateAndProject(preparedSnapshot); - assertAdvertisedIdentity( - preparedSnapshot.rootEntity, - projectionQuads, - options.peerSigner, - options.evmSigner.address, + const projectionSnapshot = snapshotAgentProfileProductionInputV1( + context, + prepared, ); active = true; const controller = new AbortController(); try { - await options.fence(preparedSnapshot, controller.signal); + await options.fence(projectionSnapshot.preparedSnapshot, controller.signal); } catch (error) { active = false; throw error; @@ -505,8 +97,7 @@ export function createAgentProfileProducerV1( state = 'completing'; try { return await completePrepared( - preparedSnapshot, - projectionQuads, + projectionSnapshot, publication, controller.signal, ); @@ -527,227 +118,3 @@ export function createAgentProfileProducerV1( }, }); } - -interface PublicationArtifactsInputV1 { - readonly envelope: SignedAgentProfileHeadEnvelopeV1; - readonly envelopeBytes: Uint8Array; - readonly bundle: Uint8Array; - readonly bundleDigest: Digest32V1; - readonly ownedSubjectTableBytes: Uint8Array; - readonly ownedSubjectTableDigest: Digest32V1; - readonly inventory: SystemRecordInventoryTreeSnapshotV1; - readonly inventoryUpdate: ReturnType | null; -} - -function publicationArtifacts( - input: PublicationArtifactsInputV1, -): AgentProfileProducerPublicationArtifactsV1 { - const inventoryObjects = input.inventoryUpdate === null - ? [...input.inventory.objects.entries()].map(([digest, stored]) => ({ digest, ...stored })) - : input.inventoryUpdate.writes; - return Object.freeze({ - head: freezeArtifact('agent-profile-head', input.envelope.objectDigest, input.envelopeBytes), - bundle: freezeArtifact('profile-bundle', input.bundleDigest, input.bundle), - ownedSubjectTable: freezeArtifact( - 'owned-subject-table', - input.ownedSubjectTableDigest, - input.ownedSubjectTableBytes, - ), - inventoryObjects: Object.freeze(inventoryObjects.map((object) => freezeArtifact( - object.objectKind, - object.digest, - object.canonicalBytes, - ))), - }); -} - -function freezeArtifact( - objectKind: Kind, - objectDigest: Digest32V1, - bytes: Uint8Array, -): AgentProfileProducerArtifactV1 { - return Object.freeze({ - objectKind, - objectDigest, - canonicalBytes: Uint8Array.from(bytes), - }); -} - -function applyInventoryUpdate( - previous: SystemRecordInventoryTreeSnapshotV1, - update: ReturnType, -): SystemRecordInventoryTreeSnapshotV1 { - if (!update.changed) return previous; - const objects = new Map(previous.objects); - for (const write of update.writes) { - objects.set(write.digest, Object.freeze({ - objectKind: write.objectKind, - object: write.object, - canonicalBytes: Uint8Array.from(write.canonicalBytes), - })); - } - return Object.freeze({ - networkId: previous.networkId, - descriptor: update.descriptor, - descriptorDigest: update.descriptorDigest, - objects, - }); -} - -function validateAndProject( - prepared: PreparedAgentProfileV1, -): readonly Readonly[] { - const projected = prepared.projectionQuads.map((quad) => Object.freeze({ ...quad })); - projected.sort(compareQuads); - for (let index = 1; index < projected.length; index += 1) { - if (compareQuads(projected[index - 1]!, projected[index]!) === 0) { - throw new Error('profile projection must be canonical and duplicate-free'); - } - } - try { - assertAgentProfileProjectionSchemaV1( - prepared.rootEntity, - ownedSubjects(prepared.rootEntity, projected), - projected, - ); - } catch (cause) { - throw new Error('profile projection is outside schema V1', { cause }); - } - return Object.freeze(projected); -} - -function snapshotPreparedProfileV1(prepared: PreparedAgentProfileV1): PreparedAgentProfileV1 { - if (!Array.isArray(prepared.publicationQuads) - || !Array.isArray(prepared.projectionQuads) - || typeof prepared.rootEntity !== 'string' - || typeof prepared.lastSeen !== 'string') { - throw new TypeError('prepared profile has an invalid structural shape'); - } - return Object.freeze({ - publicationQuads: Object.freeze( - prepared.publicationQuads.map((quad) => Object.freeze({ ...quad })), - ), - projectionQuads: Object.freeze( - prepared.projectionQuads.map((quad) => Object.freeze({ ...quad })), - ), - rootEntity: prepared.rootEntity, - lastSeen: prepared.lastSeen, - }); -} - -type ConfirmedAgentProfilePublicationBindingV1 = Readonly< - Omit & { - readonly publicationStatus: 'confirmed'; - } ->; - -function snapshotConfirmedPublicationBindingV1( - publication: AgentProfilePublicationBindingV1, -): ConfirmedAgentProfilePublicationBindingV1 { - if (publication.publicationStatus !== 'confirmed') { - throw new Error('agent-profile system record requires a confirmed publication'); - } - return Object.freeze({ - publicationStatus: 'confirmed', - assertionCoordinate: publication.assertionCoordinate, - seal: Object.freeze({ ...publication.seal }), - issuedAt: publication.issuedAt, - validUntil: publication.validUntil, - projectionSchemaDigest: publication.projectionSchemaDigest, - }); -} - -function producerNowMs(value: number): number { - if (!Number.isSafeInteger(value) || value < 0) { - throw new Error('agent-profile producer clock returned an invalid value'); - } - return value; -} - -function assertPublicationLaneV1( - seal: Readonly, - networkId: NetworkIdV1, - deployment: Readonly, - evmIssuer: string, -): void { - const ual = parseDeterministicKnowledgeAssetUal(seal.kaUal); - if (deployment.networkId !== networkId - || ual.ual !== seal.kaUal - || ual.chainId !== networkId - || ual.agentAddress !== evmIssuer - || seal.assertedAtChainId !== deployment.assertedAtChainId - || seal.assertedAtKav10Address !== deployment.assertedAtKav10Address) { - throw new Error('profile publication seal belongs to a different network or deployment'); - } -} - -function ownedSubjects( - rootSubject: string, - quads: readonly Readonly[], -): OwnedSubjectTableObjectV1 { - const subjects = [...new Set(quads.map((quad) => quad.subject))].sort(compareUtf8); - if (!subjects.includes(rootSubject)) { - throw new Error('profile projection does not contain its canonical root subject'); - } - return Object.freeze(subjects); -} - -function computeProjectionContentDigest( - quads: readonly Readonly[], -): Digest32V1 { - const leaves = quads.map((quad) => keccak256( - tripleContentV10(quad.subject, quad.predicate, quad.object), - )); - const root = V10MerkleTree.computeKARoot( - new V10MerkleTree(leaves).root, - SENTINEL_NO_PRIVATE_V10, - ); - const digest = `0x${Buffer.from(root).toString('hex')}`; - assertCanonicalDigest(digest, 'profile content digest'); - return digest; -} - -function compareQuads(left: Readonly, right: Readonly): number { - return Buffer.compare( - tripleContentV10(left.subject, left.predicate, left.object), - tripleContentV10(right.subject, right.predicate, right.object), - ); -} - -function compareUtf8(left: string, right: string): number { - return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')); -} - -function normalizePublicationTimestampV1( - value: string, - label: string, -): CanonicalRfc3339SecondsV1 { - if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value)) { - throw new Error(`${label} must be an RFC3339 UTC timestamp`); - } - const millis = Date.parse(value); - if (!Number.isFinite(millis)) throw new Error(`${label} must be a valid RFC3339 UTC timestamp`); - const canonical = new Date(Math.floor(millis / 1_000) * 1_000) - .toISOString() - .replace('.000Z', 'Z'); - if (canonical !== `${value.slice(0, 19)}Z`) { - throw new Error(`${label} must be a calendar-valid RFC3339 UTC timestamp`); - } - assertCanonicalRfc3339SecondsV1(canonical, label); - return canonical; -} - -function assertAdvertisedIdentity( - rootSubject: string, - quads: readonly Readonly[], - peerSigner: SystemRecordPeerSignerV1, - evmAddress: string, -): void { - assertCanonicalEvmAddress(evmAddress, 'profile EVM issuer'); - assertAgentProfileProjectionIdentityV1({ - rootSubject, - peerId: peerSigner.peerId, - peerPublicKey: peerSigner.publicKey, - evmIssuer: evmAddress, - }, quads); -} diff --git a/packages/agent/src/system-records/in-memory-agent-profile-publication-store-v1.ts b/packages/agent/src/system-records/in-memory-agent-profile-publication-store-v1.ts index f0e56091fe..22b35c0a5a 100644 --- a/packages/agent/src/system-records/in-memory-agent-profile-publication-store-v1.ts +++ b/packages/agent/src/system-records/in-memory-agent-profile-publication-store-v1.ts @@ -12,11 +12,11 @@ import { } from '@origintrail-official/dkg-core/system-record-v1'; import { - flattenAgentProfileProducerPublicationArtifactsV1, type AgentProfileProducerPublicationCommitLeaseV1, type AgentProfileProducerPublicationCommitV1, type AgentProfileProducerPublicationStoreV1, } from './agent-profile-producer-v1.js'; +import { flattenAgentProfileProducerPublicationArtifactsV1 } from './agent-profile-producer-artifacts-v1-internal.js'; import { cloneSystemRecordArtifactV1, systemRecordArtifactKeyV1, diff --git a/packages/agent/test/agent-profile-producer-phase-boundaries.typecheck.ts b/packages/agent/test/agent-profile-producer-phase-boundaries.typecheck.ts new file mode 100644 index 0000000000..faa6113ef2 --- /dev/null +++ b/packages/agent/test/agent-profile-producer-phase-boundaries.typecheck.ts @@ -0,0 +1,35 @@ +import type { PreparedAgentProfileV1 } from '../src/profile.js'; +import { + type PreparedProfileProjectionSnapshotV1, + prepareAgentProfileProductionV1, +} from '../src/system-records/agent-profile-producer-preparation-v1.js'; +import type { + AgentProfilePublicationBindingV1, +} from '../src/system-records/agent-profile-producer-api-v1.js'; + +declare const dependencies: Parameters[0]; +declare const prepared: PreparedAgentProfileV1; +declare const validated: PreparedProfileProjectionSnapshotV1; +declare const publication: AgentProfilePublicationBindingV1; + +void prepareAgentProfileProductionV1(dependencies, validated, publication); + +// @ts-expect-error preparation accepts a snapshotted projection plan, not a raw profile. +void prepareAgentProfileProductionV1(dependencies, prepared, publication); + +// @ts-expect-error package exports block the preparation implementation phase. +type PublishedPreparationPhase = typeof import('@origintrail-official/dkg-agent/dist/system-records/agent-profile-producer-preparation-v1.js'); +// @ts-expect-error package exports block the internal artifact helper. +type PublishedArtifactHelper = typeof import('@origintrail-official/dkg-agent/dist/system-records/agent-profile-producer-artifacts-v1-internal.js'); +// @ts-expect-error package exports block the signing implementation phase. +type PublishedSigningPhase = typeof import('@origintrail-official/dkg-agent/dist/system-records/agent-profile-producer-signing-v1.js'); +// @ts-expect-error package exports block the inventory implementation phase. +type PublishedInventoryPhase = typeof import('@origintrail-official/dkg-agent/dist/system-records/agent-profile-producer-inventory-v1.js'); +// @ts-expect-error package exports block the commit implementation phase. +type PublishedCommitPhase = typeof import('@origintrail-official/dkg-agent/dist/system-records/agent-profile-producer-commit-v1.js'); + +void (undefined as PublishedPreparationPhase | undefined); +void (undefined as PublishedArtifactHelper | undefined); +void (undefined as PublishedSigningPhase | undefined); +void (undefined as PublishedInventoryPhase | undefined); +void (undefined as PublishedCommitPhase | undefined); diff --git a/packages/agent/test/system-record-agent-profile-producer-authority-v1.test.ts b/packages/agent/test/system-record-agent-profile-producer-authority-v1.test.ts new file mode 100644 index 0000000000..38a6a5e013 --- /dev/null +++ b/packages/agent/test/system-record-agent-profile-producer-authority-v1.test.ts @@ -0,0 +1,336 @@ +import { + EMPTY_OWNED_SUBJECT_TABLE_DIGEST_V1, + parseCanonicalSignedAgentProfileHeadEnvelopeV1, + type AgentProfileAuthorityTransitionV1, + type AgentProfileHeadObjectV1, + type Digest32V1, +} from '@origintrail-official/dkg-core/system-record-v1'; +import { ethers } from 'ethers'; +import { + describe, + expect, + it, + vi, +} from 'vitest'; +import { createEvmPersonalMessageSignerV1 } from '../src/evm-message-signer-v1.js'; +import { + type AgentProfileProducerPublicationCommitV1, + type AgentProfileProducerPublicationStoreV1, +} from '../src/system-records/agent-profile-producer-v1.js'; +import { createInMemoryAgentProfilePublicationStoreV1 } from '../src/system-records/in-memory-agent-profile-publication-store-v1.js'; +import { + systemRecordArtifactKeyV1, + type SystemRecordArtifactV1, +} from '../src/system-records/artifact-v1.js'; +import { + DEPLOYMENT, + NETWORK, + OTHER_PRIVATE_KEY, + createFixtureAgentProfileProducerV1 as createAgentProfileProducerV1, + envelopeArtifact, + makePrepared, + produce, + producerFixture, + publicationFor, + signHeadEnvelope, + signTransitionEnvelope, +} from './support/agent-profile-producer-v1-fixture.js'; + + +describe('agent-profile system-record producer V1 authority and lineage', () => { + it('rejects an ordinary update over a signed tombstone before install or commit', async () => { + const fixture = await producerFixture(); + const initialProducer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => {}, + install: () => {}, + }); + await produce(initialProducer, fixture.prepared, fixture.publication); + const activeEnvelope = fixture.store.snapshot().currentHead!; + const active = activeEnvelope.object; + const tombstone = { + objectType: 'agent-profile-head', + kind: 'agents', + state: 'tombstone', + networkId: active.networkId, + peerId: active.peerId, + peerPublicKey: active.peerPublicKey, + authoritySequence: active.authoritySequence, + version: '1', + previousHeadDigest: activeEnvelope.objectDigest, + evmIssuer: active.evmIssuer, + rootSubject: active.rootSubject, + projectionSchemaDigest: active.projectionSchemaDigest, + issuedAt: '2026-08-07T12:10:00Z', + ownedSubjectTableDigest: EMPTY_OWNED_SUBJECT_TABLE_DIGEST_V1, + ownedSubjectCount: '0', + projectionBytes: '0', + projectionQuads: '0', + } as AgentProfileHeadObjectV1; + const tombstoneEnvelope = await signHeadEnvelope( + tombstone, + fixture.peerSigner, + fixture.evmSigner, + ); + const prepareCommit = vi.fn(fixture.store.prepareCommit.bind(fixture.store)); + const store: AgentProfileProducerPublicationStoreV1 = { + snapshot: () => { + const snapshot = fixture.store.snapshot(); + return Object.freeze({ ...snapshot, currentHead: tombstoneEnvelope }); + }, + resolveArtifact: (reference) => fixture.store.resolveArtifact(reference), + prepareCommit, + }; + const install = vi.fn(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store, + fence: () => {}, + install, + }); + const nextPrepared = makePrepared( + fixture.peerSigner, + fixture.evmSigner.address, + '2026-08-07T12:20:00.000Z', + ); + const lease = await producer.prepare(nextPrepared); + + await expect(lease.complete(await publicationFor( + nextPrepared, + fixture.evmSigner.address, + '2026-08-07T12:20:00Z', + ))).rejects.toThrow(/tombstoned profile/); + expect(install).not.toHaveBeenCalled(); + expect(prepareCommit).not.toHaveBeenCalled(); + expect(fixture.store.snapshot().currentHead?.objectDigest).toBe(activeEnvelope.objectDigest); + }); + + it('rejects a concurrent stale writer before installing or replacing the winning head', async () => { + const fixture = await producerFixture(); + const install = vi.fn(); + const firstProducer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => {}, + install, + }); + const secondProducer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => {}, + install, + }); + const laterPublication = await publicationFor( + fixture.prepared, + fixture.evmSigner.address, + '2026-08-07T12:01:00Z', + ); + + const outcomes = await Promise.allSettled([ + produce(firstProducer, fixture.prepared, fixture.publication), + produce(secondProducer, fixture.prepared, laterPublication), + ]); + const fulfilled = outcomes.filter( + (outcome): outcome is PromiseFulfilledResult>> => + outcome.status === 'fulfilled', + ); + const rejected = outcomes.filter( + (outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected', + ); + + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect(String(rejected[0]!.reason)).toMatch(/prepared commit|snapshot is stale/); + expect(install).toHaveBeenCalledTimes(1); + expect(fixture.store.snapshot().currentHead?.objectDigest) + .toBe(fulfilled[0]!.value.headDigest); + }); + + it('rejects ordinary update authority and schema changes without replacing the head', async () => { + const fixture = await producerFixture(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => {}, + install: () => {}, + }); + const first = await produce(producer, fixture.prepared, fixture.publication); + const nextPrepared = makePrepared( + fixture.peerSigner, + fixture.evmSigner.address, + '2026-08-07T12:20:00.000Z', + ); + const nextPublication = await publicationFor( + nextPrepared, + fixture.evmSigner.address, + '2026-08-07T12:20:00Z', + ); + await expect(produce(producer, nextPrepared, { + ...nextPublication, + projectionSchemaDigest: `0x${'cd'.repeat(32)}` as Digest32V1, + })).rejects.toThrow(/changed its root or projection schema/); + expect(fixture.store.snapshot().currentHead?.objectDigest).toBe(first.headDigest); + + const otherSigner = createEvmPersonalMessageSignerV1({ + mode: 'custodial', + address: new ethers.Wallet(OTHER_PRIVATE_KEY).address, + privateKey: OTHER_PRIVATE_KEY, + purpose: 'system-record alternate authority test', + }); + const otherPrepared = makePrepared( + fixture.peerSigner, + otherSigner.address, + '2026-08-07T12:40:00.000Z', + ); + const otherProducer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: otherSigner, + store: fixture.store, + fence: () => {}, + install: () => {}, + }); + const lease = await otherProducer.prepare(otherPrepared); + await expect(lease.complete(nextPublication)).rejects.toThrow(/authority transition/); + expect(fixture.store.snapshot().currentHead?.objectDigest).toBe(first.headDigest); + }); + + it('preserves verified authority lineage on a post-transition heartbeat', async () => { + const prior = await producerFixture(); + const priorProducer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: prior.peerSigner, + evmSigner: prior.evmSigner, + store: prior.store, + fence: () => {}, + install: () => {}, + }); + await produce(priorProducer, prior.prepared, prior.publication); + const priorEnvelope = prior.store.snapshot().currentHead!; + + const nextSigner = createEvmPersonalMessageSignerV1({ + mode: 'custodial', + address: new ethers.Wallet(OTHER_PRIVATE_KEY).address, + privateKey: OTHER_PRIVATE_KEY, + purpose: 'post-transition profile test', + }); + const transitionedPrepared = makePrepared( + prior.peerSigner, + nextSigner.address, + '2026-08-07T12:20:00.000Z', + ); + const transitionedPublication = await publicationFor( + transitionedPrepared, + nextSigner.address, + '2026-08-07T12:20:00Z', + OTHER_PRIVATE_KEY, + ); + const bootstrapStore = createInMemoryAgentProfilePublicationStoreV1(); + const bootstrapProducer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: prior.peerSigner, + evmSigner: nextSigner, + store: bootstrapStore, + fence: () => {}, + install: () => {}, + }); + await produce(bootstrapProducer, transitionedPrepared, transitionedPublication); + const bootstrapEnvelope = bootstrapStore.snapshot().currentHead!; + + const transition: AgentProfileAuthorityTransitionV1 = { + objectType: 'authority-transition', + kind: 'agents', + mode: 'co-signed', + networkId: NETWORK, + peerId: prior.peerSigner.peerId, + peerPublicKey: prior.peerSigner.publicKey, + priorAuthoritySequence: '0', + nextAuthoritySequence: '1', + priorHeadDigest: priorEnvelope.objectDigest, + priorEvmIssuer: prior.evmSigner.address, + nextEvmIssuer: nextSigner.address, + nextRoot: transitionedPrepared.rootEntity, + issuedAt: '2026-08-07T12:10:00Z', + }; + const transitionEnvelope = await signTransitionEnvelope( + transition, + prior.peerSigner, + prior.evmSigner, + nextSigner, + ); + const transitionedEnvelope = await signHeadEnvelope({ + ...bootstrapEnvelope.object, + authoritySequence: '1', + acceptedTransitionDigest: transitionEnvelope.objectDigest, + }, prior.peerSigner, nextSigner); + const history = new Map(); + for (const artifact of [ + envelopeArtifact('agent-profile-head', priorEnvelope), + envelopeArtifact('authority-transition', transitionEnvelope), + ]) { + history.set(systemRecordArtifactKeyV1(artifact), artifact); + } + let pendingCommit: AgentProfileProducerPublicationCommitV1 | null = null; + const store: AgentProfileProducerPublicationStoreV1 = { + snapshot: () => Object.freeze({ inventory: null, currentHead: transitionedEnvelope }), + resolveArtifact: (reference) => history.get(systemRecordArtifactKeyV1(reference)) ?? null, + prepareCommit: (input) => { + pendingCommit = input; + return Object.freeze({ commit: () => {}, abort: () => {} }); + }, + }; + const heartbeatPrepared = makePrepared( + prior.peerSigner, + nextSigner.address, + '2026-08-07T12:30:00.000Z', + ); + const heartbeatProducer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: prior.peerSigner, + evmSigner: nextSigner, + store, + fence: () => {}, + install: () => {}, + }); + + const result = await produce( + heartbeatProducer, + heartbeatPrepared, + await publicationFor( + heartbeatPrepared, + nextSigner.address, + '2026-08-07T12:30:00Z', + OTHER_PRIVATE_KEY, + ), + ); + const headArtifact = pendingCommit!.publicationArtifacts.head; + const heartbeatEnvelope = parseCanonicalSignedAgentProfileHeadEnvelopeV1( + headArtifact.canonicalBytes, + ); + expect(result).toMatchObject({ authoritySequence: '1', version: '1' }); + expect(heartbeatEnvelope.object.acceptedTransitionDigest) + .toBe(transitionEnvelope.objectDigest); + expect(heartbeatEnvelope.object.previousHeadDigest).toBe(transitionedEnvelope.objectDigest); + }); + +}); diff --git a/packages/agent/test/system-record-agent-profile-producer-lifecycle-v1.test.ts b/packages/agent/test/system-record-agent-profile-producer-lifecycle-v1.test.ts new file mode 100644 index 0000000000..de8a05ead5 --- /dev/null +++ b/packages/agent/test/system-record-agent-profile-producer-lifecycle-v1.test.ts @@ -0,0 +1,287 @@ +import { + describe, + expect, + it, + vi, +} from 'vitest'; +import { type PreparedAgentProfileV1 } from '../src/profile.js'; +import { + DEPLOYMENT, + NETWORK, + createFixtureAgentProfileProducerV1 as createAgentProfileProducerV1, + producerFixture, +} from './support/agent-profile-producer-v1-fixture.js'; + + +describe('agent-profile system-record producer V1 lifecycle and schema admission', () => { + it('fences before publication and an aborted lease releases the local single-flight', async () => { + const fixture = await producerFixture(); + const fence = vi.fn(); + const install = vi.fn(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence, + install, + }); + const first = await producer.prepare(fixture.prepared); + expect(fence).toHaveBeenCalledTimes(1); + await expect(producer.prepare(fixture.prepared)).rejects.toThrow(/busy/); + first.abort(); + const retry = await producer.prepare(fixture.prepared); + retry.abort(); + expect(fence).toHaveBeenCalledTimes(2); + expect(install).not.toHaveBeenCalled(); + }); + + it('releases the local single-flight when the publication fence rejects', async () => { + const fixture = await producerFixture(); + const fence = vi.fn() + .mockRejectedValueOnce(new Error('transient fence failure')) + .mockResolvedValue(undefined); + const install = vi.fn(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence, + install, + }); + + await expect(producer.prepare(fixture.prepared)).rejects.toThrow(/transient fence failure/); + const retry = await producer.prepare(fixture.prepared); + retry.abort(); + expect(fence).toHaveBeenCalledTimes(2); + expect(install).not.toHaveBeenCalled(); + expect(fixture.store.snapshot().currentHead).toBeNull(); + }); + + it('aborts a reserved commit when cancellation races a blocked install', async () => { + const fixture = await producerFixture(); + const installStarted = Promise.withResolvers(); + const releaseInstall = Promise.withResolvers(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => {}, + install: async ({ signal }) => { + installStarted.resolve(); + await releaseInstall.promise; + signal.throwIfAborted(); + }, + }); + + const lease = await producer.prepare(fixture.prepared); + const completion = lease.complete(fixture.publication); + await installStarted.promise; + lease.abort(new Error('cancel blocked install')); + releaseInstall.resolve(); + + await expect(completion).rejects.toThrow(/cancel blocked install/); + expect(fixture.store.snapshot().currentHead).toBeNull(); + expect(fixture.store.snapshot().inventory).toBeNull(); + + const retry = await producer.prepare(fixture.prepared); + await expect(retry.complete(fixture.publication)).resolves.toMatchObject({ version: '0' }); + }); + + it('commits the advertisement when cancellation arrives after successful installation', async () => { + const fixture = await producerFixture(); + let lease: Awaited>; + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => {}, + install: () => { lease.abort(new Error('late cancellation')); }, + }); + + lease = await producer.prepare(fixture.prepared); + await expect(lease.complete(fixture.publication)).resolves.toMatchObject({ version: '0' }); + expect(fixture.store.snapshot().currentHead?.object.version).toBe('0'); + expect(fixture.store.snapshot().inventory).not.toBeNull(); + }); + + it('rejects duplicate canonical profile triples before fencing publication', async () => { + const fixture = await producerFixture(); + const fence = vi.fn(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence, + install: () => {}, + }); + const duplicate = Object.freeze({ + ...fixture.prepared, + projectionQuads: Object.freeze([ + ...fixture.prepared.projectionQuads, + fixture.prepared.projectionQuads[0]!, + ]), + }); + + await expect(producer.prepare(duplicate)).rejects.toThrow(/duplicate-free/); + expect(fence).not.toHaveBeenCalled(); + }); + + it('rejects an out-of-schema profile predicate before fencing publication', async () => { + const fixture = await producerFixture(); + const fence = vi.fn(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence, + install: () => {}, + }); + const outOfSchema = Object.freeze({ + ...fixture.prepared, + projectionQuads: Object.freeze([ + ...fixture.prepared.projectionQuads, + Object.freeze({ + subject: fixture.prepared.rootEntity, + predicate: 'https://example.org/unapproved', + object: '"x"', + graph: '', + }), + ]), + }); + + await expect(producer.prepare(outOfSchema)).rejects.toThrow(/outside schema V1/); + expect(fence).not.toHaveBeenCalled(); + }); + + it.each([ + { + label: 'a literal profile link', + mutate: (prepared: PreparedAgentProfileV1) => prepared.projectionQuads.map((quad) => ( + quad.predicate === 'http://www.w3.org/ns/prov#wasGeneratedBy' + ? { ...quad, object: '"not-an-iri"' } + : quad + )), + }, + { + label: 'an unapproved rdf:type object', + mutate: (prepared: PreparedAgentProfileV1) => prepared.projectionQuads.map((quad) => ( + quad.subject === prepared.rootEntity + && quad.predicate === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type' + && quad.object === 'https://dkg.network/ontology#Agent' + ? { ...quad, object: 'https://example.org/InvalidAgentType' } + : quad + )), + }, + { + label: 'an underived x25519 revocation subject', + mutate: (prepared: PreparedAgentProfileV1) => [ + ...prepared.projectionQuads, + { + subject: prepared.rootEntity, + predicate: 'https://dkg.network/ontology#publicEncryptionKey', + object: `"${Buffer.alloc(32, 9).toString('base64url')}"`, + graph: '', + }, + { + subject: `${prepared.rootEntity}#x25519-${'0'.repeat(32)}`, + predicate: 'https://dkg.network/ontology#revokedAt', + object: '"2026-08-07T12:00:00Z"', + graph: '', + }, + ], + }, + ])('rejects $label before fencing publication', async ({ mutate }) => { + const fixture = await producerFixture(); + const fence = vi.fn(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence, + install: () => {}, + }); + const malformed = Object.freeze({ + ...fixture.prepared, + projectionQuads: Object.freeze(mutate(fixture.prepared)), + }); + + await expect(producer.prepare(malformed)).rejects.toThrow(/outside schema V1/); + expect(fence).not.toHaveBeenCalled(); + expect(fixture.store.snapshot().currentHead).toBeNull(); + }); + + it.each([ + ['peerId', '"12D3KooWRhLYc1qpzVncrVpMkykB3ML1PoQ9G9gX9X9G9gX9X9G"'], + ['agentAddress', `"0x${'33'.repeat(20)}"`], + ['publicKey', '"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="'], + ])('rejects a mismatched advertised %s before fencing publication', async (field, object) => { + const fixture = await producerFixture(); + const fence = vi.fn(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence, + install: () => {}, + }); + const predicate = `https://dkg.network/ontology#${field}`; + const mismatched = Object.freeze({ + ...fixture.prepared, + projectionQuads: Object.freeze(fixture.prepared.projectionQuads.map((quad) => Object.freeze( + quad.predicate === predicate ? { ...quad, object } : quad, + ))), + }); + + await expect(producer.prepare(mismatched)).rejects.toThrow(/does not bind the signed/); + expect(fence).not.toHaveBeenCalled(); + }); + + it.each([ + ['peerId', '"12D3KooWRhLYc1qpzVncrVpMkykB3ML1PoQ9G9gX9X9G9gX9X9G"'], + ['agentAddress', `"0x${'33'.repeat(20)}"`], + ['publicKey', '"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="'], + ])('rejects a conflicting advertised %s before fencing publication', async (field, object) => { + const fixture = await producerFixture(); + const fence = vi.fn(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence, + install: () => {}, + }); + const conflicting = Object.freeze({ + ...fixture.prepared, + projectionQuads: Object.freeze([ + ...fixture.prepared.projectionQuads, + Object.freeze({ + subject: fixture.prepared.rootEntity, + predicate: `https://dkg.network/ontology#${field}`, + object, + graph: '', + }), + ]), + }); + + await expect(producer.prepare(conflicting)).rejects.toThrow(/does not bind the signed/); + expect(fence).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/agent/test/system-record-agent-profile-producer-publication-v1.test.ts b/packages/agent/test/system-record-agent-profile-producer-publication-v1.test.ts new file mode 100644 index 0000000000..61b224f3b2 --- /dev/null +++ b/packages/agent/test/system-record-agent-profile-producer-publication-v1.test.ts @@ -0,0 +1,320 @@ +import { + parseCanonicalSignedAgentProfileHeadEnvelopeV1, + type Digest32V1, + type SignedAgentProfileHeadEnvelopeV1, +} from '@origintrail-official/dkg-core/system-record-v1'; +import { + describe, + expect, + it, + vi, +} from 'vitest'; +import { prepareAgentProfileV1 } from '../src/profile.js'; +import type { AgentProfileProducerPublicationArtifactsV1 } from '../src/system-records/agent-profile-producer-api-v1.js'; +import { flattenAgentProfileProducerPublicationArtifactsV1 } from '../src/system-records/agent-profile-producer-artifacts-v1-internal.js'; +import { type AgentProfileProducerPublicationStoreV1 } from '../src/system-records/agent-profile-producer-v1.js'; +import { + DEPLOYMENT, + NETWORK, + createFixtureAgentProfileProducerV1 as createAgentProfileProducerV1, + controlRequest, + makePrepared, + observingStore, + produce, + producerFixture, + publicationFor, + rootRequest, +} from './support/agent-profile-producer-v1-fixture.js'; + + +describe('agent-profile system-record producer V1 publication and inventory', () => { + it('flattens every publication artifact group through one ordered path', () => { + const artifact = ( + objectKind: 'agent-profile-head' | 'profile-bundle' | 'owned-subject-table' + | 'inventory-internal' | 'inventory-leaf', + byte: number, + ) => Object.freeze({ + objectKind, + objectDigest: `0x${byte.toString(16).padStart(64, '0')}` as Digest32V1, + canonicalBytes: Uint8Array.of(byte), + }); + const artifacts = { + head: artifact('agent-profile-head', 1), + bundle: artifact('profile-bundle', 2), + ownedSubjectTable: artifact('owned-subject-table', 3), + inventoryObjects: Object.freeze([ + artifact('inventory-internal', 4), + artifact('inventory-leaf', 5), + ]), + } as AgentProfileProducerPublicationArtifactsV1; + + expect(flattenAgentProfileProducerPublicationArtifactsV1(artifacts).map( + ({ objectKind, canonicalBytes }) => [objectKind, canonicalBytes[0]], + )).toEqual([ + ['agent-profile-head', 1], + ['profile-bundle', 2], + ['owned-subject-table', 3], + ['inventory-internal', 4], + ['inventory-leaf', 5], + ]); + }); + + it('stages one exact profile, installs it, then advertises the signed inventory root', async () => { + const fixture = await producerFixture(); + const events: string[] = []; + const store = observingStore(fixture.store, events); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store, + fence: () => { events.push('fence'); }, + install: (input) => { + events.push('install'); + expect(input.projectionQuads).toHaveLength(fixture.prepared.projectionQuads.length); + expect(input.head.rootSubject).toBe(fixture.prepared.rootEntity); + expect(input.verifiedAuthoritySummary.candidateHeadDigest).toBe(input.envelope.objectDigest); + }, + }); + + const result = await produce(producer, fixture.prepared, fixture.publication); + expect(result).toMatchObject({ version: '0', authoritySequence: '0' }); + expect(result.inventoryWrites).toBe(2); + expect(events).toEqual(['fence', 'install', 'advertise']); + + const snapshot = store.snapshot(); + expect(snapshot.currentHead?.objectDigest).toBe(result.headDigest); + expect(snapshot.inventory?.descriptorDigest).toBe(result.rootDescriptorDigest); + const head = await store.resolve(controlRequest(result.headDigest), new AbortController().signal); + expect(head?.objectKind).toBe('agent-profile-head'); + if (head === null) throw new Error('published profile head is missing'); + const parsedHead = parseCanonicalSignedAgentProfileHeadEnvelopeV1(head.canonicalBytes); + expect(parsedHead.object.version).toBe('0'); + const bundle = await store.resolve({ + type: 'object', objectKind: 'profile-bundle', objectDigest: parsedHead.object.bundleDigest, + }, new AbortController().signal); + expect(bundle?.objectKind).toBe('profile-bundle'); + const subjectTable = await store.resolve({ + type: 'object', objectKind: 'owned-subject-table', + objectDigest: parsedHead.object.ownedSubjectTableDigest, + }, new AbortController().signal); + expect(subjectTable?.objectKind).toBe('owned-subject-table'); + const root = await store.resolve(rootRequest(), new AbortController().signal); + expect(root?.objectKind).toBe('root-descriptor'); + expect(root?.objectDigest).toBe(result.rootDescriptorDigest); + const inventoryDigest = snapshot.inventory?.descriptor.treeRootDigest; + const inventoryObject = inventoryDigest === undefined + ? undefined + : snapshot.inventory?.objects.get(inventoryDigest); + expect(inventoryObject).toBeDefined(); + if (inventoryObject === undefined || inventoryDigest === undefined) { + throw new Error('published inventory object is missing'); + } + await expect(store.resolve({ + type: 'inventory-object', rootDescriptorDigest: result.rootDescriptorDigest, path: [], + objectKind: inventoryObject.objectKind, objectDigest: inventoryDigest, + }, new AbortController().signal)).resolves.toMatchObject({ + objectKind: inventoryObject.objectKind, + objectDigest: inventoryDigest, + }); + await expect(store.resolve({ + type: 'inventory-object', rootDescriptorDigest: `0x${'ff'.repeat(32)}`, path: [], + objectKind: inventoryObject.objectKind, objectDigest: inventoryDigest, + }, new AbortController().signal)).resolves.toBeNull(); + }); + + it('accepts the signed projection for advertised skills and hosted context graphs', async () => { + const fixture = await producerFixture(); + const prepared = prepareAgentProfileV1({ + peerId: fixture.peerSigner.peerId, + publicKey: Buffer.from(fixture.peerSigner.publicKey, 'base64url').toString('base64'), + agentAddress: fixture.evmSigner.address, + name: 'Feature profile fixture', + framework: 'Hermes', + nodeRole: 'edge', + lastSeen: '2026-08-07T12:00:00.000Z', + skills: [{ + skillType: 'ImageAnalysis', + pricePerCall: 1, + currency: 'TRAC', + successRate: 0.99, + pricingModel: 'PerInvocation', + }], + contextGraphsServed: ['public-image-analysis'], + }); + const install = vi.fn(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => {}, + install, + }); + + await expect(produce( + producer, + prepared, + await publicationFor(prepared, fixture.evmSigner.address, '2026-08-07T12:00:00Z'), + )).resolves.toMatchObject({ version: '0', authoritySequence: '0' }); + const installedSubjects = new Set( + install.mock.calls[0]![0].projectionQuads.map((quad) => quad.subject), + ); + expect(installedSubjects).toContain(`${prepared.rootEntity}/.well-known/genid/cap1`); + expect(installedSubjects).toContain(`${prepared.rootEntity}/.well-known/genid/offering1`); + expect(installedSubjects).toContain(`${prepared.rootEntity}/.well-known/genid/hosting`); + }); + + it('advances one COW path for an ordinary same-authority heartbeat', async () => { + const fixture = await producerFixture(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => {}, + install: () => {}, + }); + const first = await produce(producer, fixture.prepared, fixture.publication); + const nextPrepared = makePrepared( + fixture.peerSigner, + fixture.evmSigner.address, + '2026-08-07T12:20:00.000Z', + ); + const second = await produce( + producer, + nextPrepared, + await publicationFor(nextPrepared, fixture.evmSigner.address, '2026-08-07T12:20:00Z'), + ); + + expect(second.version).toBe('1'); + expect(second.headDigest).not.toBe(first.headDigest); + expect(second.rootDescriptorDigest).not.toBe(first.rootDescriptorDigest); + expect(second.inventoryWrites).toBeLessThanOrEqual(6); + expect(second.inventoryWriteBytes).toBeLessThanOrEqual(1024 * 1024); + expect(fixture.store.snapshot().inventory?.descriptor.totalRows).toBe('1'); + }); + + it('serves an advertised inventory root after a newer root commits', async () => { + const fixture = await producerFixture(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => {}, + install: () => {}, + }); + const first = await produce(producer, fixture.prepared, fixture.publication); + const firstInventory = fixture.store.snapshot().inventory!; + const firstTreeDigest = firstInventory.descriptor.treeRootDigest; + const firstTreeObject = firstInventory.objects.get(firstTreeDigest)!; + const nextPrepared = makePrepared( + fixture.peerSigner, + fixture.evmSigner.address, + '2026-08-07T12:20:00.000Z', + ); + + await produce( + producer, + nextPrepared, + await publicationFor(nextPrepared, fixture.evmSigner.address, '2026-08-07T12:20:00Z'), + ); + + await expect(fixture.store.resolve({ + type: 'inventory-object', + rootDescriptorDigest: first.rootDescriptorDigest, + path: [], + objectKind: firstTreeObject.objectKind, + objectDigest: firstTreeDigest, + }, new AbortController().signal)).resolves.toMatchObject({ + objectKind: firstTreeObject.objectKind, + objectDigest: firstTreeDigest, + }); + await expect(fixture.store.resolve({ + type: 'inventory-object', + rootDescriptorDigest: first.rootDescriptorDigest, + path: [0], + objectKind: firstTreeObject.objectKind, + objectDigest: firstTreeDigest, + }, new AbortController().signal)).resolves.toBeNull(); + }); + + it.each([ + [ + 'belongs to a different stable record', + (head: SignedAgentProfileHeadEnvelopeV1): SignedAgentProfileHeadEnvelopeV1 => ({ + ...head, + object: { ...head.object, peerId: '12D3KooWDifferentStableRecord111111111111111111111111' }, + }), + /different stable record/, + ], + [ + 'has an invalid signature', + (head: SignedAgentProfileHeadEnvelopeV1): SignedAgentProfileHeadEnvelopeV1 => ({ + ...head, + signatures: head.signatures.map((signature, index) => index === 0 + ? { ...signature, signature: Buffer.alloc(64).toString('base64url') } + : signature), + }), + /stored profile head signature verification failed/, + ], + ])('rejects a previous head that %s before installing or committing', async ( + _label, + mutateHead, + expected, + ) => { + const fixture = await producerFixture(); + const initialProducer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => {}, + install: () => {}, + }); + await produce(initialProducer, fixture.prepared, fixture.publication); + const prepareCommit = vi.fn(fixture.store.prepareCommit.bind(fixture.store)); + const store: AgentProfileProducerPublicationStoreV1 = { + snapshot: () => { + const snapshot = fixture.store.snapshot(); + return Object.freeze({ + ...snapshot, + currentHead: mutateHead(snapshot.currentHead!), + }); + }, + resolveArtifact: (reference) => fixture.store.resolveArtifact(reference), + prepareCommit, + }; + const install = vi.fn(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store, + fence: () => {}, + install, + }); + const nextPrepared = makePrepared( + fixture.peerSigner, + fixture.evmSigner.address, + '2026-08-07T12:20:00.000Z', + ); + const lease = await producer.prepare(nextPrepared); + + await expect(lease.complete(await publicationFor( + nextPrepared, + fixture.evmSigner.address, + '2026-08-07T12:20:00Z', + ))).rejects.toThrow(expected); + expect(install).not.toHaveBeenCalled(); + expect(prepareCommit).not.toHaveBeenCalled(); + }); + +}); diff --git a/packages/agent/test/system-record-agent-profile-producer-v1.test.ts b/packages/agent/test/system-record-agent-profile-producer-v1.test.ts deleted file mode 100644 index 9f0c58b157..0000000000 --- a/packages/agent/test/system-record-agent-profile-producer-v1.test.ts +++ /dev/null @@ -1,1521 +0,0 @@ -import { - EMPTY_OWNED_SUBJECT_TABLE_DIGEST_V1, - SYSTEM_RECORD_MAX_CLOCK_SKEW_MS, - buildSystemRecordSignatureMessageV1, - canonicalizeSignedSystemRecordEnvelopeV1, - computeAgentProfileAuthorityTransitionDigestV1, - computeAgentProfileHeadObjectDigestV1, - parseCanonicalSignedAgentProfileHeadEnvelopeV1, - type AgentProfileAuthorityTransitionV1, - type AgentProfileHeadObjectV1, - type Digest32V1, - type SignedAgentProfileAuthorityTransitionEnvelopeV1, - type SignedAgentProfileHeadEnvelopeV1, - type SystemRecordPeerPublicKeyV1, -} from '@origintrail-official/dkg-core/system-record-v1'; -import { ethers } from 'ethers'; -import { describe, expect, it, vi } from 'vitest'; - -import { - createEvmPersonalMessageSignerV1, - type EvmPersonalMessageSignerV1, -} from '../src/evm-message-signer-v1.js'; -import { prepareAgentProfileV1, type PreparedAgentProfileV1 } from '../src/profile.js'; -import { - type AgentProfileProducerPublicationCommitV1, - type AgentProfileProducerPublicationStoreV1, - type AgentProfileProducerV1, - type AgentProfilePublicationBindingV1, - type SystemRecordPeerSignerV1, -} from '../src/system-records/agent-profile-producer-v1.js'; -import { - createInMemoryAgentProfilePublicationStoreV1, - type InMemoryAgentProfilePublicationStoreV1, -} from '../src/system-records/in-memory-agent-profile-publication-store-v1.js'; -import { - systemRecordArtifactKeyV1, - type SystemRecordArtifactV1, -} from '../src/system-records/artifact-v1.js'; -import { - DEPLOYMENT, - NETWORK, - OTHER_PRIVATE_KEY, - createFixtureAgentProfileProducerV1 as createAgentProfileProducerV1, - controlRequest, - envelopeArtifact, - makePrepared, - observingStore, - produce, - producerFixture, - publicationFor, - rootRequest, - signHeadEnvelope, - signTransitionEnvelope, -} from './support/agent-profile-producer-v1-fixture.js'; - -describe('agent-profile system-record producer V1', () => { - it('stages one exact profile, installs it, then advertises the signed inventory root', async () => { - const fixture = await producerFixture(); - const events: string[] = []; - const store = observingStore(fixture.store, events); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store, - fence: () => { events.push('fence'); }, - install: (input) => { - events.push('install'); - expect(input.projectionQuads).toHaveLength(fixture.prepared.projectionQuads.length); - expect(input.head.rootSubject).toBe(fixture.prepared.rootEntity); - expect(input.verifiedAuthoritySummary.candidateHeadDigest).toBe(input.envelope.objectDigest); - }, - }); - - const result = await produce(producer, fixture.prepared, fixture.publication); - expect(result).toMatchObject({ version: '0', authoritySequence: '0' }); - expect(result.inventoryWrites).toBe(2); - expect(events).toEqual(['fence', 'install', 'advertise']); - - const snapshot = store.snapshot(); - expect(snapshot.currentHead?.objectDigest).toBe(result.headDigest); - expect(snapshot.inventory?.descriptorDigest).toBe(result.rootDescriptorDigest); - const head = await store.resolve(controlRequest(result.headDigest), new AbortController().signal); - expect(head?.objectKind).toBe('agent-profile-head'); - if (head === null) throw new Error('published profile head is missing'); - const parsedHead = parseCanonicalSignedAgentProfileHeadEnvelopeV1(head.canonicalBytes); - expect(parsedHead.object.version).toBe('0'); - const bundle = await store.resolve({ - type: 'object', objectKind: 'profile-bundle', objectDigest: parsedHead.object.bundleDigest, - }, new AbortController().signal); - expect(bundle?.objectKind).toBe('profile-bundle'); - const subjectTable = await store.resolve({ - type: 'object', objectKind: 'owned-subject-table', - objectDigest: parsedHead.object.ownedSubjectTableDigest, - }, new AbortController().signal); - expect(subjectTable?.objectKind).toBe('owned-subject-table'); - const root = await store.resolve(rootRequest(), new AbortController().signal); - expect(root?.objectKind).toBe('root-descriptor'); - expect(root?.objectDigest).toBe(result.rootDescriptorDigest); - const inventoryDigest = snapshot.inventory?.descriptor.treeRootDigest; - const inventoryObject = inventoryDigest === undefined - ? undefined - : snapshot.inventory?.objects.get(inventoryDigest); - expect(inventoryObject).toBeDefined(); - if (inventoryObject === undefined || inventoryDigest === undefined) { - throw new Error('published inventory object is missing'); - } - await expect(store.resolve({ - type: 'inventory-object', rootDescriptorDigest: result.rootDescriptorDigest, path: [], - objectKind: inventoryObject.objectKind, objectDigest: inventoryDigest, - }, new AbortController().signal)).resolves.toMatchObject({ - objectKind: inventoryObject.objectKind, - objectDigest: inventoryDigest, - }); - await expect(store.resolve({ - type: 'inventory-object', rootDescriptorDigest: `0x${'ff'.repeat(32)}`, path: [], - objectKind: inventoryObject.objectKind, objectDigest: inventoryDigest, - }, new AbortController().signal)).resolves.toBeNull(); - }); - - it('accepts the signed projection for advertised skills and hosted context graphs', async () => { - const fixture = await producerFixture(); - const prepared = prepareAgentProfileV1({ - peerId: fixture.peerSigner.peerId, - publicKey: Buffer.from(fixture.peerSigner.publicKey, 'base64url').toString('base64'), - agentAddress: fixture.evmSigner.address, - name: 'Feature profile fixture', - framework: 'Hermes', - nodeRole: 'edge', - lastSeen: '2026-08-07T12:00:00.000Z', - skills: [{ - skillType: 'ImageAnalysis', - pricePerCall: 1, - currency: 'TRAC', - successRate: 0.99, - pricingModel: 'PerInvocation', - }], - contextGraphsServed: ['public-image-analysis'], - }); - const install = vi.fn(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence: () => {}, - install, - }); - - await expect(produce( - producer, - prepared, - await publicationFor(prepared, fixture.evmSigner.address, '2026-08-07T12:00:00Z'), - )).resolves.toMatchObject({ version: '0', authoritySequence: '0' }); - const installedSubjects = new Set( - install.mock.calls[0]![0].projectionQuads.map((quad) => quad.subject), - ); - expect(installedSubjects).toContain(`${prepared.rootEntity}/.well-known/genid/cap1`); - expect(installedSubjects).toContain(`${prepared.rootEntity}/.well-known/genid/offering1`); - expect(installedSubjects).toContain(`${prepared.rootEntity}/.well-known/genid/hosting`); - }); - - it('advances one COW path for an ordinary same-authority heartbeat', async () => { - const fixture = await producerFixture(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence: () => {}, - install: () => {}, - }); - const first = await produce(producer, fixture.prepared, fixture.publication); - const nextPrepared = makePrepared( - fixture.peerSigner, - fixture.evmSigner.address, - '2026-08-07T12:20:00.000Z', - ); - const second = await produce( - producer, - nextPrepared, - await publicationFor(nextPrepared, fixture.evmSigner.address, '2026-08-07T12:20:00Z'), - ); - - expect(second.version).toBe('1'); - expect(second.headDigest).not.toBe(first.headDigest); - expect(second.rootDescriptorDigest).not.toBe(first.rootDescriptorDigest); - expect(second.inventoryWrites).toBeLessThanOrEqual(6); - expect(second.inventoryWriteBytes).toBeLessThanOrEqual(1024 * 1024); - expect(fixture.store.snapshot().inventory?.descriptor.totalRows).toBe('1'); - }); - - it('serves an advertised inventory root after a newer root commits', async () => { - const fixture = await producerFixture(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence: () => {}, - install: () => {}, - }); - const first = await produce(producer, fixture.prepared, fixture.publication); - const firstInventory = fixture.store.snapshot().inventory!; - const firstTreeDigest = firstInventory.descriptor.treeRootDigest; - const firstTreeObject = firstInventory.objects.get(firstTreeDigest)!; - const nextPrepared = makePrepared( - fixture.peerSigner, - fixture.evmSigner.address, - '2026-08-07T12:20:00.000Z', - ); - - await produce( - producer, - nextPrepared, - await publicationFor(nextPrepared, fixture.evmSigner.address, '2026-08-07T12:20:00Z'), - ); - - await expect(fixture.store.resolve({ - type: 'inventory-object', - rootDescriptorDigest: first.rootDescriptorDigest, - path: [], - objectKind: firstTreeObject.objectKind, - objectDigest: firstTreeDigest, - }, new AbortController().signal)).resolves.toMatchObject({ - objectKind: firstTreeObject.objectKind, - objectDigest: firstTreeDigest, - }); - await expect(fixture.store.resolve({ - type: 'inventory-object', - rootDescriptorDigest: first.rootDescriptorDigest, - path: [0], - objectKind: firstTreeObject.objectKind, - objectDigest: firstTreeDigest, - }, new AbortController().signal)).resolves.toBeNull(); - }); - - it.each([ - [ - 'belongs to a different stable record', - (head: SignedAgentProfileHeadEnvelopeV1): SignedAgentProfileHeadEnvelopeV1 => ({ - ...head, - object: { ...head.object, peerId: '12D3KooWDifferentStableRecord111111111111111111111111' }, - }), - /different stable record/, - ], - [ - 'has an invalid signature', - (head: SignedAgentProfileHeadEnvelopeV1): SignedAgentProfileHeadEnvelopeV1 => ({ - ...head, - signatures: head.signatures.map((signature, index) => index === 0 - ? { ...signature, signature: Buffer.alloc(64).toString('base64url') } - : signature), - }), - /stored profile head signature verification failed/, - ], - ])('rejects a previous head that %s before installing or committing', async ( - _label, - mutateHead, - expected, - ) => { - const fixture = await producerFixture(); - const initialProducer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence: () => {}, - install: () => {}, - }); - await produce(initialProducer, fixture.prepared, fixture.publication); - const prepareCommit = vi.fn(fixture.store.prepareCommit.bind(fixture.store)); - const store: AgentProfileProducerPublicationStoreV1 = { - snapshot: () => { - const snapshot = fixture.store.snapshot(); - return Object.freeze({ - ...snapshot, - currentHead: mutateHead(snapshot.currentHead!), - }); - }, - resolveArtifact: (reference) => fixture.store.resolveArtifact(reference), - prepareCommit, - }; - const install = vi.fn(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store, - fence: () => {}, - install, - }); - const nextPrepared = makePrepared( - fixture.peerSigner, - fixture.evmSigner.address, - '2026-08-07T12:20:00.000Z', - ); - const lease = await producer.prepare(nextPrepared); - - await expect(lease.complete(await publicationFor( - nextPrepared, - fixture.evmSigner.address, - '2026-08-07T12:20:00Z', - ))).rejects.toThrow(expected); - expect(install).not.toHaveBeenCalled(); - expect(prepareCommit).not.toHaveBeenCalled(); - }); - - it('rejects an ordinary update over a signed tombstone before install or commit', async () => { - const fixture = await producerFixture(); - const initialProducer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence: () => {}, - install: () => {}, - }); - await produce(initialProducer, fixture.prepared, fixture.publication); - const activeEnvelope = fixture.store.snapshot().currentHead!; - const active = activeEnvelope.object; - const tombstone = { - objectType: 'agent-profile-head', - kind: 'agents', - state: 'tombstone', - networkId: active.networkId, - peerId: active.peerId, - peerPublicKey: active.peerPublicKey, - authoritySequence: active.authoritySequence, - version: '1', - previousHeadDigest: activeEnvelope.objectDigest, - evmIssuer: active.evmIssuer, - rootSubject: active.rootSubject, - projectionSchemaDigest: active.projectionSchemaDigest, - issuedAt: '2026-08-07T12:10:00Z', - ownedSubjectTableDigest: EMPTY_OWNED_SUBJECT_TABLE_DIGEST_V1, - ownedSubjectCount: '0', - projectionBytes: '0', - projectionQuads: '0', - } as AgentProfileHeadObjectV1; - const tombstoneEnvelope = await signHeadEnvelope( - tombstone, - fixture.peerSigner, - fixture.evmSigner, - ); - const prepareCommit = vi.fn(fixture.store.prepareCommit.bind(fixture.store)); - const store: AgentProfileProducerPublicationStoreV1 = { - snapshot: () => { - const snapshot = fixture.store.snapshot(); - return Object.freeze({ ...snapshot, currentHead: tombstoneEnvelope }); - }, - resolveArtifact: (reference) => fixture.store.resolveArtifact(reference), - prepareCommit, - }; - const install = vi.fn(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store, - fence: () => {}, - install, - }); - const nextPrepared = makePrepared( - fixture.peerSigner, - fixture.evmSigner.address, - '2026-08-07T12:20:00.000Z', - ); - const lease = await producer.prepare(nextPrepared); - - await expect(lease.complete(await publicationFor( - nextPrepared, - fixture.evmSigner.address, - '2026-08-07T12:20:00Z', - ))).rejects.toThrow(/tombstoned profile/); - expect(install).not.toHaveBeenCalled(); - expect(prepareCommit).not.toHaveBeenCalled(); - expect(fixture.store.snapshot().currentHead?.objectDigest).toBe(activeEnvelope.objectDigest); - }); - - it('rejects a concurrent stale writer before installing or replacing the winning head', async () => { - const fixture = await producerFixture(); - const install = vi.fn(); - const firstProducer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence: () => {}, - install, - }); - const secondProducer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence: () => {}, - install, - }); - const laterPublication = await publicationFor( - fixture.prepared, - fixture.evmSigner.address, - '2026-08-07T12:01:00Z', - ); - - const outcomes = await Promise.allSettled([ - produce(firstProducer, fixture.prepared, fixture.publication), - produce(secondProducer, fixture.prepared, laterPublication), - ]); - const fulfilled = outcomes.filter( - (outcome): outcome is PromiseFulfilledResult>> => - outcome.status === 'fulfilled', - ); - const rejected = outcomes.filter( - (outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected', - ); - - expect(fulfilled).toHaveLength(1); - expect(rejected).toHaveLength(1); - expect(String(rejected[0]!.reason)).toMatch(/prepared commit|snapshot is stale/); - expect(install).toHaveBeenCalledTimes(1); - expect(fixture.store.snapshot().currentHead?.objectDigest) - .toBe(fulfilled[0]!.value.headDigest); - }); - - it('rejects ordinary update authority and schema changes without replacing the head', async () => { - const fixture = await producerFixture(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence: () => {}, - install: () => {}, - }); - const first = await produce(producer, fixture.prepared, fixture.publication); - const nextPrepared = makePrepared( - fixture.peerSigner, - fixture.evmSigner.address, - '2026-08-07T12:20:00.000Z', - ); - const nextPublication = await publicationFor( - nextPrepared, - fixture.evmSigner.address, - '2026-08-07T12:20:00Z', - ); - await expect(produce(producer, nextPrepared, { - ...nextPublication, - projectionSchemaDigest: `0x${'cd'.repeat(32)}` as Digest32V1, - })).rejects.toThrow(/changed its root or projection schema/); - expect(fixture.store.snapshot().currentHead?.objectDigest).toBe(first.headDigest); - - const otherSigner = createEvmPersonalMessageSignerV1({ - mode: 'custodial', - address: new ethers.Wallet(OTHER_PRIVATE_KEY).address, - privateKey: OTHER_PRIVATE_KEY, - purpose: 'system-record alternate authority test', - }); - const otherPrepared = makePrepared( - fixture.peerSigner, - otherSigner.address, - '2026-08-07T12:40:00.000Z', - ); - const otherProducer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: otherSigner, - store: fixture.store, - fence: () => {}, - install: () => {}, - }); - const lease = await otherProducer.prepare(otherPrepared); - await expect(lease.complete(nextPublication)).rejects.toThrow(/authority transition/); - expect(fixture.store.snapshot().currentHead?.objectDigest).toBe(first.headDigest); - }); - - it('preserves verified authority lineage on a post-transition heartbeat', async () => { - const prior = await producerFixture(); - const priorProducer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: prior.peerSigner, - evmSigner: prior.evmSigner, - store: prior.store, - fence: () => {}, - install: () => {}, - }); - await produce(priorProducer, prior.prepared, prior.publication); - const priorEnvelope = prior.store.snapshot().currentHead!; - - const nextSigner = createEvmPersonalMessageSignerV1({ - mode: 'custodial', - address: new ethers.Wallet(OTHER_PRIVATE_KEY).address, - privateKey: OTHER_PRIVATE_KEY, - purpose: 'post-transition profile test', - }); - const transitionedPrepared = makePrepared( - prior.peerSigner, - nextSigner.address, - '2026-08-07T12:20:00.000Z', - ); - const transitionedPublication = await publicationFor( - transitionedPrepared, - nextSigner.address, - '2026-08-07T12:20:00Z', - OTHER_PRIVATE_KEY, - ); - const bootstrapStore = createInMemoryAgentProfilePublicationStoreV1(); - const bootstrapProducer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: prior.peerSigner, - evmSigner: nextSigner, - store: bootstrapStore, - fence: () => {}, - install: () => {}, - }); - await produce(bootstrapProducer, transitionedPrepared, transitionedPublication); - const bootstrapEnvelope = bootstrapStore.snapshot().currentHead!; - - const transition: AgentProfileAuthorityTransitionV1 = { - objectType: 'authority-transition', - kind: 'agents', - mode: 'co-signed', - networkId: NETWORK, - peerId: prior.peerSigner.peerId, - peerPublicKey: prior.peerSigner.publicKey, - priorAuthoritySequence: '0', - nextAuthoritySequence: '1', - priorHeadDigest: priorEnvelope.objectDigest, - priorEvmIssuer: prior.evmSigner.address, - nextEvmIssuer: nextSigner.address, - nextRoot: transitionedPrepared.rootEntity, - issuedAt: '2026-08-07T12:10:00Z', - }; - const transitionEnvelope = await signTransitionEnvelope( - transition, - prior.peerSigner, - prior.evmSigner, - nextSigner, - ); - const transitionedEnvelope = await signHeadEnvelope({ - ...bootstrapEnvelope.object, - authoritySequence: '1', - acceptedTransitionDigest: transitionEnvelope.objectDigest, - }, prior.peerSigner, nextSigner); - const history = new Map(); - for (const artifact of [ - envelopeArtifact('agent-profile-head', priorEnvelope), - envelopeArtifact('authority-transition', transitionEnvelope), - ]) { - history.set(systemRecordArtifactKeyV1(artifact), artifact); - } - let pendingCommit: AgentProfileProducerPublicationCommitV1 | null = null; - const store: AgentProfileProducerPublicationStoreV1 = { - snapshot: () => Object.freeze({ inventory: null, currentHead: transitionedEnvelope }), - resolveArtifact: (reference) => history.get(systemRecordArtifactKeyV1(reference)) ?? null, - prepareCommit: (input) => { - pendingCommit = input; - return Object.freeze({ commit: () => {}, abort: () => {} }); - }, - }; - const heartbeatPrepared = makePrepared( - prior.peerSigner, - nextSigner.address, - '2026-08-07T12:30:00.000Z', - ); - const heartbeatProducer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: prior.peerSigner, - evmSigner: nextSigner, - store, - fence: () => {}, - install: () => {}, - }); - - const result = await produce( - heartbeatProducer, - heartbeatPrepared, - await publicationFor( - heartbeatPrepared, - nextSigner.address, - '2026-08-07T12:30:00Z', - OTHER_PRIVATE_KEY, - ), - ); - const headArtifact = pendingCommit!.publicationArtifacts.head; - const heartbeatEnvelope = parseCanonicalSignedAgentProfileHeadEnvelopeV1( - headArtifact.canonicalBytes, - ); - expect(result).toMatchObject({ authoritySequence: '1', version: '1' }); - expect(heartbeatEnvelope.object.acceptedTransitionDigest) - .toBe(transitionEnvelope.objectDigest); - expect(heartbeatEnvelope.object.previousHeadDigest).toBe(transitionedEnvelope.objectDigest); - }); - - it('preflights provider capacity before materialization and releases a failed commit lease', async () => { - const fixture = await producerFixture(createInMemoryAgentProfilePublicationStoreV1({ - maxObjects: 1, - maxBytes: 1024 * 1024, - })); - const install = vi.fn(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence: () => {}, - install, - }); - await expect(produce(producer, fixture.prepared, fixture.publication)) - .rejects.toThrow(/cache capacity exhausted/); - expect(install).not.toHaveBeenCalled(); - expect(fixture.store.snapshot().currentHead).toBeNull(); - - const durableStore = createInMemoryAgentProfilePublicationStoreV1(); - const retryFixture = await producerFixture(durableStore); - let fail = true; - const retrying = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: retryFixture.peerSigner, - evmSigner: retryFixture.evmSigner, - store: durableStore, - fence: () => {}, - install: () => { - if (fail) throw new Error('injected materialization failure'); - }, - }); - await expect(produce(retrying, retryFixture.prepared, retryFixture.publication)) - .rejects.toThrow(/injected materialization failure/); - expect(durableStore.snapshot().currentHead).toBeNull(); - fail = false; - await expect(produce(retrying, retryFixture.prepared, retryFixture.publication)).resolves.toMatchObject({ - version: '0', - }); - }); - - it('counts the retained root descriptor against cache capacity on rollover', async () => { - const store = createInMemoryAgentProfilePublicationStoreV1({ - maxObjects: 7, - maxBytes: 1024 * 1024, - }); - const fixture = await producerFixture(store); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store, - fence: () => {}, - install: () => {}, - }); - const first = await produce(producer, fixture.prepared, fixture.publication); - const nextPrepared = makePrepared( - fixture.peerSigner, - fixture.evmSigner.address, - '2026-08-07T12:20:00.000Z', - ); - - await expect(produce( - producer, - nextPrepared, - await publicationFor(nextPrepared, fixture.evmSigner.address, '2026-08-07T12:20:00Z'), - )).rejects.toThrow(/cache capacity exhausted/); - expect(store.snapshot().currentHead?.objectDigest).toBe(first.headDigest); - }); - - it('does not expose mutable store state through snapshots', async () => { - const fixture = await producerFixture(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence: () => {}, - install: () => {}, - }); - const published = await produce(producer, fixture.prepared, fixture.publication); - const snapshot = fixture.store.snapshot(); - (snapshot.inventory!.objects as Map).clear(); - (snapshot.currentHead!.object as { version: string }).version = '99'; - - const fresh = fixture.store.snapshot(); - expect(fresh.inventory!.objects.size).toBeGreaterThan(0); - expect(fresh.currentHead?.objectDigest).toBe(published.headDigest); - expect(fresh.currentHead?.object.version).toBe('0'); - }); - - it('defensively snapshots a structurally valid mutable prepared profile', async () => { - const fixture = await producerFixture(); - const fence = vi.fn(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence, - install: () => {}, - }); - const mutable = { - publicationQuads: fixture.prepared.publicationQuads.map((quad) => ({ ...quad })), - projectionQuads: fixture.prepared.projectionQuads.map((quad) => ({ ...quad })), - rootEntity: fixture.prepared.rootEntity, - lastSeen: fixture.prepared.lastSeen, - }; - - const lease = await producer.prepare(mutable); - mutable.publicationQuads.length = 0; - mutable.projectionQuads.length = 0; - mutable.rootEntity = 'urn:mutated-after-prepare'; - await expect(lease.complete(fixture.publication)).resolves.toMatchObject({ version: '0' }); - expect(fence).toHaveBeenCalledWith( - expect.objectContaining({ rootEntity: fixture.prepared.rootEntity }), - expect.any(AbortSignal), - ); - expect(fixture.store.snapshot().currentHead?.object.rootSubject) - .toBe(fixture.prepared.rootEntity); - }); - - it('defensively snapshots the publication binding across later signing awaits', async () => { - const fixture = await producerFixture(); - const originalFinalizedAt = fixture.publication.seal.assertionFinalizedAt; - const mutableSeal = { ...fixture.publication.seal }; - const mutablePublication = { - ...fixture.publication, - seal: mutableSeal, - } as AgentProfilePublicationBindingV1; - let peerSignatureCount = 0; - const peerSign = vi.fn(async (message: Uint8Array) => { - peerSignatureCount += 1; - if (peerSignatureCount === 2) { - mutableSeal.assertionFinalizedAt = '2026-08-07T12:15:00.000Z'; - } - return fixture.peerSigner.sign(message); - }); - const install = vi.fn(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: { ...fixture.peerSigner, sign: peerSign }, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence: () => {}, - install, - }); - - await expect(produce(producer, fixture.prepared, mutablePublication)) - .resolves.toMatchObject({ version: '0' }); - expect(mutableSeal.assertionFinalizedAt).not.toBe(originalFinalizedAt); - expect(install.mock.calls[0]![0].head.graphScopedAuthorSeal.assertionFinalizedAt) - .toBe(originalFinalizedAt); - expect(fixture.store.snapshot().currentHead?.object.graphScopedAuthorSeal.assertionFinalizedAt) - .toBe(originalFinalizedAt); - }); - - it('fails closed when the publication seal is not for the exact prepared bytes', async () => { - const fixture = await producerFixture(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence: () => {}, - install: () => {}, - }); - const tampered = { - ...fixture.publication, - seal: { ...fixture.publication.seal, assertionMerkleRoot: `0x${'cd'.repeat(32)}` }, - } as AgentProfilePublicationBindingV1; - await expect(produce(producer, fixture.prepared, tampered)).rejects.toThrow(/exact public projection/); - expect(fixture.store.snapshot().currentHead).toBeNull(); - }); - - it('rejects a non-confirmed publication before installation', async () => { - const fixture = await producerFixture(); - const install = vi.fn(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence: () => {}, - install, - }); - const publication: AgentProfilePublicationBindingV1 = { - ...fixture.publication, - publicationStatus: 'tentative', - }; - - await expect(produce(producer, fixture.prepared, publication)) - .rejects.toThrow(/requires a confirmed publication/); - expect(install).not.toHaveBeenCalled(); - expect(fixture.store.snapshot().currentHead).toBeNull(); - }); - - it.each([ - [ - 'a different author address', - (publication: AgentProfilePublicationBindingV1) => ({ - ...publication, - seal: { ...publication.seal, authorAddress: new ethers.Wallet(OTHER_PRIVATE_KEY).address.toLowerCase() }, - }), - /exact public projection/, - ], - [ - 'a wrong public triple count', - (publication: AgentProfilePublicationBindingV1) => ({ - ...publication, - seal: { ...publication.seal, publicTripleCount: '999' }, - }), - /exact public projection/, - ], - [ - 'private triples', - (publication: AgentProfilePublicationBindingV1) => ({ - ...publication, - seal: { ...publication.seal, privateTripleCount: '1' }, - }), - /exact public projection/, - ], - [ - 'a private Merkle root', - (publication: AgentProfilePublicationBindingV1) => ({ - ...publication, - seal: { ...publication.seal, privateMerkleRoot: `0x${'cd'.repeat(32)}` }, - }), - /exact public projection/, - ], - ])('rejects publication binding with %s before installation', async (_label, mutate, expected) => { - const fixture = await producerFixture(); - const install = vi.fn(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence: () => {}, - install, - }); - const publication = mutate(fixture.publication) as unknown as AgentProfilePublicationBindingV1; - - await expect(produce(producer, fixture.prepared, publication)).rejects.toThrow(expected); - expect(install).not.toHaveBeenCalled(); - expect(fixture.store.snapshot().currentHead).toBeNull(); - }); - - it('rejects a future-dated publication against an independent producer clock', async () => { - const fixture = await producerFixture(); - const install = vi.fn(); - const peerSign = vi.fn(fixture.peerSigner.sign); - const nowMs = Date.parse('2026-08-07T12:00:00Z'); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: { ...fixture.peerSigner, sign: peerSign }, - evmSigner: fixture.evmSigner, - store: fixture.store, - nowMs: () => nowMs, - fence: () => {}, - install, - }); - const issuedAt = new Date(nowMs + SYSTEM_RECORD_MAX_CLOCK_SKEW_MS + 1_000) - .toISOString() - .replace('.000Z', 'Z'); - - await expect(produce( - producer, - fixture.prepared, - await publicationFor(fixture.prepared, fixture.evmSigner.address, issuedAt), - )).rejects.toThrow(/future clock-skew bound/); - expect(peerSign).not.toHaveBeenCalled(); - expect(install).not.toHaveBeenCalled(); - expect(fixture.store.snapshot().currentHead).toBeNull(); - }); - - it('rejects a head issue time before assertion finalization without side effects', async () => { - const fixture = await producerFixture(); - const peerSign = vi.fn(fixture.peerSigner.sign); - const evmSignMessage = vi.fn(fixture.evmSigner.signMessage); - const prepareCommit = vi.fn(fixture.store.prepareCommit.bind(fixture.store)); - const install = vi.fn(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: { ...fixture.peerSigner, sign: peerSign }, - evmSigner: { ...fixture.evmSigner, signMessage: evmSignMessage }, - store: { - snapshot: () => fixture.store.snapshot(), - resolveArtifact: (reference) => fixture.store.resolveArtifact(reference), - prepareCommit, - }, - fence: () => {}, - install, - }); - - await expect(produce(producer, fixture.prepared, { - ...fixture.publication, - seal: { - ...fixture.publication.seal, - assertionFinalizedAt: '2026-08-07T13:00:00.000Z', - }, - })).rejects.toThrow(/issuedAt predates assertion finalization/); - expect(peerSign).not.toHaveBeenCalled(); - expect(evmSignMessage).not.toHaveBeenCalled(); - expect(prepareCommit).not.toHaveBeenCalled(); - expect(install).not.toHaveBeenCalled(); - expect(fixture.store.snapshot().currentHead).toBeNull(); - }); - - it('preserves millisecond finalization precision in the issue-time ordering check', async () => { - const fixture = await producerFixture(); - const peerSign = vi.fn(fixture.peerSigner.sign); - const install = vi.fn(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: { ...fixture.peerSigner, sign: peerSign }, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence: () => {}, - install, - }); - const finalizedWithinSecond = { - ...fixture.publication, - seal: { - ...fixture.publication.seal, - assertionFinalizedAt: '2026-08-07T12:00:00.999Z', - }, - } as AgentProfilePublicationBindingV1; - - await expect(produce(producer, fixture.prepared, finalizedWithinSecond)) - .rejects.toThrow(/issuedAt predates assertion finalization/); - expect(peerSign).not.toHaveBeenCalled(); - expect(install).not.toHaveBeenCalled(); - - await expect(produce(producer, fixture.prepared, { - ...finalizedWithinSecond, - issuedAt: '2026-08-07T12:00:01Z', - })).resolves.toMatchObject({ version: '0' }); - expect(install).toHaveBeenCalledOnce(); - expect(fixture.store.snapshot().currentHead?.object.issuedAt).toBe('2026-08-07T12:00:01Z'); - }); - - it.each([ - [ - 'a foreign UAL network', - (publication: AgentProfilePublicationBindingV1) => ({ - ...publication, - seal: { - ...publication.seal, - kaUal: publication.seal.kaUal.replace('did:dkg:base:84532/', 'did:dkg:base:1/'), - }, - }), - ], - [ - 'a foreign UAL account', - (publication: AgentProfilePublicationBindingV1) => ({ - ...publication, - seal: { - ...publication.seal, - kaUal: publication.seal.kaUal.replace( - /\/0x[0-9a-f]{40}\//, - `/0x${'22'.repeat(20)}/`, - ), - }, - }), - ], - [ - 'a foreign asserted chain', - (publication: AgentProfilePublicationBindingV1) => ({ - ...publication, - seal: { ...publication.seal, assertedAtChainId: '1' }, - }), - ], - [ - 'a foreign KAv10 deployment', - (publication: AgentProfilePublicationBindingV1) => ({ - ...publication, - seal: { - ...publication.seal, - assertedAtKav10Address: `0x${'55'.repeat(20)}`, - }, - }), - ], - ])('rejects %s before signing or publication side effects', async (_label, mutate) => { - const fixture = await producerFixture(); - const peerSign = vi.fn(fixture.peerSigner.sign); - const evmSignMessage = vi.fn(fixture.evmSigner.signMessage); - const install = vi.fn(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: { ...fixture.peerSigner, sign: peerSign }, - evmSigner: { ...fixture.evmSigner, signMessage: evmSignMessage }, - store: fixture.store, - fence: () => {}, - install, - }); - const publication = mutate(fixture.publication) as unknown as AgentProfilePublicationBindingV1; - - await expect(produce(producer, fixture.prepared, publication)) - .rejects.toThrow(/different network or deployment/); - expect(peerSign).not.toHaveBeenCalled(); - expect(evmSignMessage).not.toHaveBeenCalled(); - expect(install).not.toHaveBeenCalled(); - expect(fixture.store.snapshot().currentHead).toBeNull(); - }); - - it('rejects a non-positive validity window before signing or committing', async () => { - const fixture = await producerFixture(); - const peerSign = vi.fn(fixture.peerSigner.sign); - const install = vi.fn(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: { ...fixture.peerSigner, sign: peerSign }, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence: () => {}, - install, - }); - - await expect(produce(producer, fixture.prepared, { - ...fixture.publication, - validUntil: fixture.publication.issuedAt, - })).rejects.toThrow(/validUntil must be later than issuedAt/); - expect(peerSign).not.toHaveBeenCalled(); - expect(install).not.toHaveBeenCalled(); - expect(fixture.store.snapshot().currentHead).toBeNull(); - }); - - it('rejects an already-expired validity window before signing, install, or commit', async () => { - const fixture = await producerFixture(); - const peerSign = vi.fn(fixture.peerSigner.sign); - const evmSignMessage = vi.fn(fixture.evmSigner.signMessage); - const prepareCommit = vi.fn(fixture.store.prepareCommit.bind(fixture.store)); - const store: AgentProfileProducerPublicationStoreV1 = { - snapshot: () => fixture.store.snapshot(), - resolveArtifact: (reference) => fixture.store.resolveArtifact(reference), - prepareCommit, - }; - const install = vi.fn(); - const nowMs = Date.parse('2026-08-07T12:00:00Z'); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: { ...fixture.peerSigner, sign: peerSign }, - evmSigner: { ...fixture.evmSigner, signMessage: evmSignMessage }, - store, - nowMs: () => nowMs, - fence: () => {}, - install, - }); - - await expect(produce(producer, fixture.prepared, { - ...fixture.publication, - issuedAt: '2026-08-06T00:00:00Z', - validUntil: '2026-08-06T01:00:00Z', - seal: { - ...fixture.publication.seal, - assertionFinalizedAt: '2026-08-06T00:00:00.000Z', - }, - })).rejects.toThrow(/already expired/); - expect(peerSign).not.toHaveBeenCalled(); - expect(evmSignMessage).not.toHaveBeenCalled(); - expect(install).not.toHaveBeenCalled(); - expect(prepareCommit).not.toHaveBeenCalled(); - expect(fixture.store.snapshot().currentHead).toBeNull(); - }); - - it('rejects an oversized encoded profile bundle before signing, install, or commit', async () => { - const fixture = await producerFixture(); - const multiaddrs = Array.from({ length: 6_000 }, (_, index) => ( - `/dns4/profile-${index.toString().padStart(4, '0')}-${'a'.repeat(96)}.example/tcp/4001` - )); - const prepared = prepareAgentProfileV1({ - peerId: fixture.peerSigner.peerId, - publicKey: Buffer.from(fixture.peerSigner.publicKey, 'base64url').toString('base64'), - agentAddress: fixture.evmSigner.address, - name: 'Oversized profile fixture', - nodeRole: 'edge', - lastSeen: '2026-08-07T12:00:00.000Z', - skills: [], - multiaddrs, - }); - const peerSign = vi.fn(fixture.peerSigner.sign); - const evmSignMessage = vi.fn(fixture.evmSigner.signMessage); - const prepareCommit = vi.fn(fixture.store.prepareCommit.bind(fixture.store)); - const install = vi.fn(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: { ...fixture.peerSigner, sign: peerSign }, - evmSigner: { ...fixture.evmSigner, signMessage: evmSignMessage }, - store: { - snapshot: () => fixture.store.snapshot(), - resolveArtifact: (reference) => fixture.store.resolveArtifact(reference), - prepareCommit, - }, - fence: () => {}, - install, - }); - - await expect(produce( - producer, - prepared, - await publicationFor(prepared, fixture.evmSigner.address, '2026-08-07T12:00:00Z'), - )).rejects.toThrow(/profile bundle exceeds/); - expect(peerSign).not.toHaveBeenCalled(); - expect(evmSignMessage).not.toHaveBeenCalled(); - expect(prepareCommit).not.toHaveBeenCalled(); - expect(install).not.toHaveBeenCalled(); - expect(fixture.store.snapshot().currentHead).toBeNull(); - }); - - it('normalizes millisecond publication timestamps before signing the head', async () => { - const fixture = await producerFixture(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence: () => {}, - install: () => {}, - }); - await produce(producer, fixture.prepared, { - ...fixture.publication, - issuedAt: '2026-08-07T12:00:00.123Z', - validUntil: '2026-08-08T12:00:00.987Z', - }); - - expect(fixture.store.snapshot().currentHead?.object).toMatchObject({ - issuedAt: '2026-08-07T12:00:00Z', - validUntil: '2026-08-08T12:00:00Z', - }); - }); - - it('rejects invalid publication timestamp scalars before signing or committing', async () => { - const fixture = await producerFixture(); - const peerSign = vi.fn(fixture.peerSigner.sign); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: { ...fixture.peerSigner, sign: peerSign }, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence: () => {}, - install: () => {}, - }); - - await expect(produce(producer, fixture.prepared, { - ...fixture.publication, - issuedAt: '2026-02-30T12:00:00.000Z', - })).rejects.toThrow(/calendar-valid/); - expect(peerSign).not.toHaveBeenCalled(); - expect(fixture.store.snapshot().currentHead).toBeNull(); - }); - - it('rejects a seal whose author attestation does not recover the profile authority', async () => { - const fixture = await producerFixture(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence: () => {}, - install: () => {}, - }); - const tampered = { - ...fixture.publication, - seal: { - ...fixture.publication.seal, - authorAttestationR: `0x${'22'.repeat(32)}`, - authorAttestationVS: `0x${'33'.repeat(32)}`, - }, - } as AgentProfilePublicationBindingV1; - await expect(produce(producer, fixture.prepared, tampered)) - .rejects.toThrow(/attestation does not recover/); - expect(fixture.store.snapshot().currentHead).toBeNull(); - }); - - it('rolls back when the peer signer returns an invalid profile-head signature', async () => { - const fixture = await producerFixture(); - const install = vi.fn(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: { ...fixture.peerSigner, sign: async () => new Uint8Array(64) }, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence: () => {}, - install, - }); - - await expect(produce(producer, fixture.prepared, fixture.publication)) - .rejects.toThrow(/head signature verification failed/); - expect(install).not.toHaveBeenCalled(); - expect(fixture.store.snapshot().currentHead).toBeNull(); - }); - - it('rolls back when the peer signer returns an invalid inventory-root signature', async () => { - const fixture = await producerFixture(); - const install = vi.fn(); - let signatureNumber = 0; - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: { - ...fixture.peerSigner, - sign: async (message) => { - signatureNumber += 1; - return signatureNumber === 1 - ? fixture.peerSigner.sign(message) - : new Uint8Array(64); - }, - }, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence: () => {}, - install, - }); - - await expect(produce(producer, fixture.prepared, fixture.publication)) - .rejects.toThrow(/inventory root signature verification failed/); - expect(install).not.toHaveBeenCalled(); - expect(fixture.store.snapshot().currentHead).toBeNull(); - }); - - it('fences before publication and an aborted lease releases the local single-flight', async () => { - const fixture = await producerFixture(); - const fence = vi.fn(); - const install = vi.fn(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence, - install, - }); - const first = await producer.prepare(fixture.prepared); - expect(fence).toHaveBeenCalledTimes(1); - await expect(producer.prepare(fixture.prepared)).rejects.toThrow(/busy/); - first.abort(); - const retry = await producer.prepare(fixture.prepared); - retry.abort(); - expect(fence).toHaveBeenCalledTimes(2); - expect(install).not.toHaveBeenCalled(); - }); - - it('releases the local single-flight when the publication fence rejects', async () => { - const fixture = await producerFixture(); - const fence = vi.fn() - .mockRejectedValueOnce(new Error('transient fence failure')) - .mockResolvedValue(undefined); - const install = vi.fn(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence, - install, - }); - - await expect(producer.prepare(fixture.prepared)).rejects.toThrow(/transient fence failure/); - const retry = await producer.prepare(fixture.prepared); - retry.abort(); - expect(fence).toHaveBeenCalledTimes(2); - expect(install).not.toHaveBeenCalled(); - expect(fixture.store.snapshot().currentHead).toBeNull(); - }); - - it('aborts a reserved commit when cancellation races a blocked install', async () => { - const fixture = await producerFixture(); - const installStarted = Promise.withResolvers(); - const releaseInstall = Promise.withResolvers(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence: () => {}, - install: async ({ signal }) => { - installStarted.resolve(); - await releaseInstall.promise; - signal.throwIfAborted(); - }, - }); - - const lease = await producer.prepare(fixture.prepared); - const completion = lease.complete(fixture.publication); - await installStarted.promise; - lease.abort(new Error('cancel blocked install')); - releaseInstall.resolve(); - - await expect(completion).rejects.toThrow(/cancel blocked install/); - expect(fixture.store.snapshot().currentHead).toBeNull(); - expect(fixture.store.snapshot().inventory).toBeNull(); - - const retry = await producer.prepare(fixture.prepared); - await expect(retry.complete(fixture.publication)).resolves.toMatchObject({ version: '0' }); - }); - - it('commits the advertisement when cancellation arrives after successful installation', async () => { - const fixture = await producerFixture(); - let lease: Awaited>; - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence: () => {}, - install: () => { lease.abort(new Error('late cancellation')); }, - }); - - lease = await producer.prepare(fixture.prepared); - await expect(lease.complete(fixture.publication)).resolves.toMatchObject({ version: '0' }); - expect(fixture.store.snapshot().currentHead?.object.version).toBe('0'); - expect(fixture.store.snapshot().inventory).not.toBeNull(); - }); - - it('rejects duplicate canonical profile triples before fencing publication', async () => { - const fixture = await producerFixture(); - const fence = vi.fn(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence, - install: () => {}, - }); - const duplicate = Object.freeze({ - ...fixture.prepared, - projectionQuads: Object.freeze([ - ...fixture.prepared.projectionQuads, - fixture.prepared.projectionQuads[0]!, - ]), - }); - - await expect(producer.prepare(duplicate)).rejects.toThrow(/duplicate-free/); - expect(fence).not.toHaveBeenCalled(); - }); - - it('rejects an out-of-schema profile predicate before fencing publication', async () => { - const fixture = await producerFixture(); - const fence = vi.fn(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence, - install: () => {}, - }); - const outOfSchema = Object.freeze({ - ...fixture.prepared, - projectionQuads: Object.freeze([ - ...fixture.prepared.projectionQuads, - Object.freeze({ - subject: fixture.prepared.rootEntity, - predicate: 'https://example.org/unapproved', - object: '"x"', - graph: '', - }), - ]), - }); - - await expect(producer.prepare(outOfSchema)).rejects.toThrow(/outside schema V1/); - expect(fence).not.toHaveBeenCalled(); - }); - - it.each([ - { - label: 'a literal profile link', - mutate: (prepared: PreparedAgentProfileV1) => prepared.projectionQuads.map((quad) => ( - quad.predicate === 'http://www.w3.org/ns/prov#wasGeneratedBy' - ? { ...quad, object: '"not-an-iri"' } - : quad - )), - }, - { - label: 'an unapproved rdf:type object', - mutate: (prepared: PreparedAgentProfileV1) => prepared.projectionQuads.map((quad) => ( - quad.subject === prepared.rootEntity - && quad.predicate === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type' - && quad.object === 'https://dkg.network/ontology#Agent' - ? { ...quad, object: 'https://example.org/InvalidAgentType' } - : quad - )), - }, - { - label: 'an underived x25519 revocation subject', - mutate: (prepared: PreparedAgentProfileV1) => [ - ...prepared.projectionQuads, - { - subject: prepared.rootEntity, - predicate: 'https://dkg.network/ontology#publicEncryptionKey', - object: `"${Buffer.alloc(32, 9).toString('base64url')}"`, - graph: '', - }, - { - subject: `${prepared.rootEntity}#x25519-${'0'.repeat(32)}`, - predicate: 'https://dkg.network/ontology#revokedAt', - object: '"2026-08-07T12:00:00Z"', - graph: '', - }, - ], - }, - ])('rejects $label before fencing publication', async ({ mutate }) => { - const fixture = await producerFixture(); - const fence = vi.fn(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence, - install: () => {}, - }); - const malformed = Object.freeze({ - ...fixture.prepared, - projectionQuads: Object.freeze(mutate(fixture.prepared)), - }); - - await expect(producer.prepare(malformed)).rejects.toThrow(/outside schema V1/); - expect(fence).not.toHaveBeenCalled(); - expect(fixture.store.snapshot().currentHead).toBeNull(); - }); - - it.each([ - ['peerId', '"12D3KooWRhLYc1qpzVncrVpMkykB3ML1PoQ9G9gX9X9G9gX9X9G"'], - ['agentAddress', `"0x${'33'.repeat(20)}"`], - ['publicKey', '"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="'], - ])('rejects a mismatched advertised %s before fencing publication', async (field, object) => { - const fixture = await producerFixture(); - const fence = vi.fn(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence, - install: () => {}, - }); - const predicate = `https://dkg.network/ontology#${field}`; - const mismatched = Object.freeze({ - ...fixture.prepared, - projectionQuads: Object.freeze(fixture.prepared.projectionQuads.map((quad) => Object.freeze( - quad.predicate === predicate ? { ...quad, object } : quad, - ))), - }); - - await expect(producer.prepare(mismatched)).rejects.toThrow(/does not bind the signed/); - expect(fence).not.toHaveBeenCalled(); - }); - - it.each([ - ['peerId', '"12D3KooWRhLYc1qpzVncrVpMkykB3ML1PoQ9G9gX9X9G9gX9X9G"'], - ['agentAddress', `"0x${'33'.repeat(20)}"`], - ['publicKey', '"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="'], - ])('rejects a conflicting advertised %s before fencing publication', async (field, object) => { - const fixture = await producerFixture(); - const fence = vi.fn(); - const producer = createAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence, - install: () => {}, - }); - const conflicting = Object.freeze({ - ...fixture.prepared, - projectionQuads: Object.freeze([ - ...fixture.prepared.projectionQuads, - Object.freeze({ - subject: fixture.prepared.rootEntity, - predicate: `https://dkg.network/ontology#${field}`, - object, - graph: '', - }), - ]), - }); - - await expect(producer.prepare(conflicting)).rejects.toThrow(/does not bind the signed/); - expect(fence).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/agent/test/system-record-agent-profile-producer-validation-v1.test.ts b/packages/agent/test/system-record-agent-profile-producer-validation-v1.test.ts new file mode 100644 index 0000000000..e17dde0cda --- /dev/null +++ b/packages/agent/test/system-record-agent-profile-producer-validation-v1.test.ts @@ -0,0 +1,669 @@ +import { SYSTEM_RECORD_MAX_CLOCK_SKEW_MS } from '@origintrail-official/dkg-core/system-record-v1'; +import { ethers } from 'ethers'; +import { + describe, + expect, + it, + vi, +} from 'vitest'; +import { prepareAgentProfileV1 } from '../src/profile.js'; +import { + type AgentProfileProducerPublicationStoreV1, + type AgentProfilePublicationBindingV1, +} from '../src/system-records/agent-profile-producer-v1.js'; +import { createInMemoryAgentProfilePublicationStoreV1 } from '../src/system-records/in-memory-agent-profile-publication-store-v1.js'; +import { + DEPLOYMENT, + NETWORK, + OTHER_PRIVATE_KEY, + createFixtureAgentProfileProducerV1 as createAgentProfileProducerV1, + makePrepared, + produce, + producerFixture, + publicationFor, +} from './support/agent-profile-producer-v1-fixture.js'; + + +describe('agent-profile system-record producer V1 validation and binding', () => { + it('preflights provider capacity before materialization and releases a failed commit lease', async () => { + const fixture = await producerFixture(createInMemoryAgentProfilePublicationStoreV1({ + maxObjects: 1, + maxBytes: 1024 * 1024, + })); + const install = vi.fn(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => {}, + install, + }); + await expect(produce(producer, fixture.prepared, fixture.publication)) + .rejects.toThrow(/cache capacity exhausted/); + expect(install).not.toHaveBeenCalled(); + expect(fixture.store.snapshot().currentHead).toBeNull(); + + const durableStore = createInMemoryAgentProfilePublicationStoreV1(); + const retryFixture = await producerFixture(durableStore); + let fail = true; + const retrying = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: retryFixture.peerSigner, + evmSigner: retryFixture.evmSigner, + store: durableStore, + fence: () => {}, + install: () => { + if (fail) throw new Error('injected materialization failure'); + }, + }); + await expect(produce(retrying, retryFixture.prepared, retryFixture.publication)) + .rejects.toThrow(/injected materialization failure/); + expect(durableStore.snapshot().currentHead).toBeNull(); + fail = false; + await expect(produce(retrying, retryFixture.prepared, retryFixture.publication)).resolves.toMatchObject({ + version: '0', + }); + }); + + it('counts the retained root descriptor against cache capacity on rollover', async () => { + const store = createInMemoryAgentProfilePublicationStoreV1({ + maxObjects: 7, + maxBytes: 1024 * 1024, + }); + const fixture = await producerFixture(store); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store, + fence: () => {}, + install: () => {}, + }); + const first = await produce(producer, fixture.prepared, fixture.publication); + const nextPrepared = makePrepared( + fixture.peerSigner, + fixture.evmSigner.address, + '2026-08-07T12:20:00.000Z', + ); + + await expect(produce( + producer, + nextPrepared, + await publicationFor(nextPrepared, fixture.evmSigner.address, '2026-08-07T12:20:00Z'), + )).rejects.toThrow(/cache capacity exhausted/); + expect(store.snapshot().currentHead?.objectDigest).toBe(first.headDigest); + }); + + it('does not expose mutable store state through snapshots', async () => { + const fixture = await producerFixture(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => {}, + install: () => {}, + }); + const published = await produce(producer, fixture.prepared, fixture.publication); + const snapshot = fixture.store.snapshot(); + (snapshot.inventory!.objects as Map).clear(); + (snapshot.currentHead!.object as { version: string }).version = '99'; + + const fresh = fixture.store.snapshot(); + expect(fresh.inventory!.objects.size).toBeGreaterThan(0); + expect(fresh.currentHead?.objectDigest).toBe(published.headDigest); + expect(fresh.currentHead?.object.version).toBe('0'); + }); + + it('defensively snapshots a structurally valid mutable prepared profile', async () => { + const fixture = await producerFixture(); + const fence = vi.fn(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence, + install: () => {}, + }); + const mutable = { + publicationQuads: fixture.prepared.publicationQuads.map((quad) => ({ ...quad })), + projectionQuads: fixture.prepared.projectionQuads.map((quad) => ({ ...quad })), + rootEntity: fixture.prepared.rootEntity, + lastSeen: fixture.prepared.lastSeen, + }; + + const lease = await producer.prepare(mutable); + mutable.publicationQuads.length = 0; + mutable.projectionQuads.length = 0; + mutable.rootEntity = 'urn:mutated-after-prepare'; + await expect(lease.complete(fixture.publication)).resolves.toMatchObject({ version: '0' }); + expect(fence).toHaveBeenCalledWith( + expect.objectContaining({ rootEntity: fixture.prepared.rootEntity }), + expect.any(AbortSignal), + ); + expect(fixture.store.snapshot().currentHead?.object.rootSubject) + .toBe(fixture.prepared.rootEntity); + }); + + it('defensively snapshots the publication binding across later signing awaits', async () => { + const fixture = await producerFixture(); + const originalFinalizedAt = fixture.publication.seal.assertionFinalizedAt; + const mutableSeal = { ...fixture.publication.seal }; + const mutablePublication = { + ...fixture.publication, + seal: mutableSeal, + } as AgentProfilePublicationBindingV1; + let peerSignatureCount = 0; + const peerSign = vi.fn(async (message: Uint8Array) => { + peerSignatureCount += 1; + if (peerSignatureCount === 2) { + mutableSeal.assertionFinalizedAt = '2026-08-07T12:15:00.000Z'; + } + return fixture.peerSigner.sign(message); + }); + const install = vi.fn(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: { ...fixture.peerSigner, sign: peerSign }, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => {}, + install, + }); + + await expect(produce(producer, fixture.prepared, mutablePublication)) + .resolves.toMatchObject({ version: '0' }); + expect(mutableSeal.assertionFinalizedAt).not.toBe(originalFinalizedAt); + expect(install.mock.calls[0]![0].head.graphScopedAuthorSeal.assertionFinalizedAt) + .toBe(originalFinalizedAt); + expect(fixture.store.snapshot().currentHead?.object.graphScopedAuthorSeal.assertionFinalizedAt) + .toBe(originalFinalizedAt); + }); + + it('fails closed when the publication seal is not for the exact prepared bytes', async () => { + const fixture = await producerFixture(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => {}, + install: () => {}, + }); + const tampered = { + ...fixture.publication, + seal: { ...fixture.publication.seal, assertionMerkleRoot: `0x${'cd'.repeat(32)}` }, + } as AgentProfilePublicationBindingV1; + await expect(produce(producer, fixture.prepared, tampered)).rejects.toThrow(/exact public projection/); + expect(fixture.store.snapshot().currentHead).toBeNull(); + }); + + it('rejects a non-confirmed publication before installation', async () => { + const fixture = await producerFixture(); + const install = vi.fn(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => {}, + install, + }); + const publication: AgentProfilePublicationBindingV1 = { + ...fixture.publication, + publicationStatus: 'tentative', + }; + + await expect(produce(producer, fixture.prepared, publication)) + .rejects.toThrow(/requires a confirmed publication/); + expect(install).not.toHaveBeenCalled(); + expect(fixture.store.snapshot().currentHead).toBeNull(); + }); + + it.each([ + [ + 'a different author address', + (publication: AgentProfilePublicationBindingV1) => ({ + ...publication, + seal: { ...publication.seal, authorAddress: new ethers.Wallet(OTHER_PRIVATE_KEY).address.toLowerCase() }, + }), + /exact public projection/, + ], + [ + 'a wrong public triple count', + (publication: AgentProfilePublicationBindingV1) => ({ + ...publication, + seal: { ...publication.seal, publicTripleCount: '999' }, + }), + /exact public projection/, + ], + [ + 'private triples', + (publication: AgentProfilePublicationBindingV1) => ({ + ...publication, + seal: { ...publication.seal, privateTripleCount: '1' }, + }), + /exact public projection/, + ], + [ + 'a private Merkle root', + (publication: AgentProfilePublicationBindingV1) => ({ + ...publication, + seal: { ...publication.seal, privateMerkleRoot: `0x${'cd'.repeat(32)}` }, + }), + /exact public projection/, + ], + ])('rejects publication binding with %s before installation', async (_label, mutate, expected) => { + const fixture = await producerFixture(); + const install = vi.fn(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => {}, + install, + }); + const publication = mutate(fixture.publication) as unknown as AgentProfilePublicationBindingV1; + + await expect(produce(producer, fixture.prepared, publication)).rejects.toThrow(expected); + expect(install).not.toHaveBeenCalled(); + expect(fixture.store.snapshot().currentHead).toBeNull(); + }); + + it('rejects a future-dated publication against an independent producer clock', async () => { + const fixture = await producerFixture(); + const install = vi.fn(); + const peerSign = vi.fn(fixture.peerSigner.sign); + const nowMs = Date.parse('2026-08-07T12:00:00Z'); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: { ...fixture.peerSigner, sign: peerSign }, + evmSigner: fixture.evmSigner, + store: fixture.store, + nowMs: () => nowMs, + fence: () => {}, + install, + }); + const issuedAt = new Date(nowMs + SYSTEM_RECORD_MAX_CLOCK_SKEW_MS + 1_000) + .toISOString() + .replace('.000Z', 'Z'); + + await expect(produce( + producer, + fixture.prepared, + await publicationFor(fixture.prepared, fixture.evmSigner.address, issuedAt), + )).rejects.toThrow(/future clock-skew bound/); + expect(peerSign).not.toHaveBeenCalled(); + expect(install).not.toHaveBeenCalled(); + expect(fixture.store.snapshot().currentHead).toBeNull(); + }); + + it('rejects a head issue time before assertion finalization without side effects', async () => { + const fixture = await producerFixture(); + const peerSign = vi.fn(fixture.peerSigner.sign); + const evmSignMessage = vi.fn(fixture.evmSigner.signMessage); + const prepareCommit = vi.fn(fixture.store.prepareCommit.bind(fixture.store)); + const install = vi.fn(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: { ...fixture.peerSigner, sign: peerSign }, + evmSigner: { ...fixture.evmSigner, signMessage: evmSignMessage }, + store: { + snapshot: () => fixture.store.snapshot(), + resolveArtifact: (reference) => fixture.store.resolveArtifact(reference), + prepareCommit, + }, + fence: () => {}, + install, + }); + + await expect(produce(producer, fixture.prepared, { + ...fixture.publication, + seal: { + ...fixture.publication.seal, + assertionFinalizedAt: '2026-08-07T13:00:00.000Z', + }, + })).rejects.toThrow(/issuedAt predates assertion finalization/); + expect(peerSign).not.toHaveBeenCalled(); + expect(evmSignMessage).not.toHaveBeenCalled(); + expect(prepareCommit).not.toHaveBeenCalled(); + expect(install).not.toHaveBeenCalled(); + expect(fixture.store.snapshot().currentHead).toBeNull(); + }); + + it('preserves millisecond finalization precision in the issue-time ordering check', async () => { + const fixture = await producerFixture(); + const peerSign = vi.fn(fixture.peerSigner.sign); + const install = vi.fn(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: { ...fixture.peerSigner, sign: peerSign }, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => {}, + install, + }); + const finalizedWithinSecond = { + ...fixture.publication, + seal: { + ...fixture.publication.seal, + assertionFinalizedAt: '2026-08-07T12:00:00.999Z', + }, + } as AgentProfilePublicationBindingV1; + + await expect(produce(producer, fixture.prepared, finalizedWithinSecond)) + .rejects.toThrow(/issuedAt predates assertion finalization/); + expect(peerSign).not.toHaveBeenCalled(); + expect(install).not.toHaveBeenCalled(); + + await expect(produce(producer, fixture.prepared, { + ...finalizedWithinSecond, + issuedAt: '2026-08-07T12:00:01Z', + })).resolves.toMatchObject({ version: '0' }); + expect(install).toHaveBeenCalledOnce(); + expect(fixture.store.snapshot().currentHead?.object.issuedAt).toBe('2026-08-07T12:00:01Z'); + }); + + it.each([ + [ + 'a foreign UAL network', + (publication: AgentProfilePublicationBindingV1) => ({ + ...publication, + seal: { + ...publication.seal, + kaUal: publication.seal.kaUal.replace('did:dkg:base:84532/', 'did:dkg:base:1/'), + }, + }), + ], + [ + 'a foreign UAL account', + (publication: AgentProfilePublicationBindingV1) => ({ + ...publication, + seal: { + ...publication.seal, + kaUal: publication.seal.kaUal.replace( + /\/0x[0-9a-f]{40}\//, + `/0x${'22'.repeat(20)}/`, + ), + }, + }), + ], + [ + 'a foreign asserted chain', + (publication: AgentProfilePublicationBindingV1) => ({ + ...publication, + seal: { ...publication.seal, assertedAtChainId: '1' }, + }), + ], + [ + 'a foreign KAv10 deployment', + (publication: AgentProfilePublicationBindingV1) => ({ + ...publication, + seal: { + ...publication.seal, + assertedAtKav10Address: `0x${'55'.repeat(20)}`, + }, + }), + ], + ])('rejects %s before signing or publication side effects', async (_label, mutate) => { + const fixture = await producerFixture(); + const peerSign = vi.fn(fixture.peerSigner.sign); + const evmSignMessage = vi.fn(fixture.evmSigner.signMessage); + const install = vi.fn(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: { ...fixture.peerSigner, sign: peerSign }, + evmSigner: { ...fixture.evmSigner, signMessage: evmSignMessage }, + store: fixture.store, + fence: () => {}, + install, + }); + const publication = mutate(fixture.publication) as unknown as AgentProfilePublicationBindingV1; + + await expect(produce(producer, fixture.prepared, publication)) + .rejects.toThrow(/different network or deployment/); + expect(peerSign).not.toHaveBeenCalled(); + expect(evmSignMessage).not.toHaveBeenCalled(); + expect(install).not.toHaveBeenCalled(); + expect(fixture.store.snapshot().currentHead).toBeNull(); + }); + + it('rejects a non-positive validity window before signing or committing', async () => { + const fixture = await producerFixture(); + const peerSign = vi.fn(fixture.peerSigner.sign); + const install = vi.fn(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: { ...fixture.peerSigner, sign: peerSign }, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => {}, + install, + }); + + await expect(produce(producer, fixture.prepared, { + ...fixture.publication, + validUntil: fixture.publication.issuedAt, + })).rejects.toThrow(/validUntil must be later than issuedAt/); + expect(peerSign).not.toHaveBeenCalled(); + expect(install).not.toHaveBeenCalled(); + expect(fixture.store.snapshot().currentHead).toBeNull(); + }); + + it('rejects an already-expired validity window before signing, install, or commit', async () => { + const fixture = await producerFixture(); + const peerSign = vi.fn(fixture.peerSigner.sign); + const evmSignMessage = vi.fn(fixture.evmSigner.signMessage); + const prepareCommit = vi.fn(fixture.store.prepareCommit.bind(fixture.store)); + const store: AgentProfileProducerPublicationStoreV1 = { + snapshot: () => fixture.store.snapshot(), + resolveArtifact: (reference) => fixture.store.resolveArtifact(reference), + prepareCommit, + }; + const install = vi.fn(); + const nowMs = Date.parse('2026-08-07T12:00:00Z'); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: { ...fixture.peerSigner, sign: peerSign }, + evmSigner: { ...fixture.evmSigner, signMessage: evmSignMessage }, + store, + nowMs: () => nowMs, + fence: () => {}, + install, + }); + + await expect(produce(producer, fixture.prepared, { + ...fixture.publication, + issuedAt: '2026-08-06T00:00:00Z', + validUntil: '2026-08-06T01:00:00Z', + seal: { + ...fixture.publication.seal, + assertionFinalizedAt: '2026-08-06T00:00:00.000Z', + }, + })).rejects.toThrow(/already expired/); + expect(peerSign).not.toHaveBeenCalled(); + expect(evmSignMessage).not.toHaveBeenCalled(); + expect(install).not.toHaveBeenCalled(); + expect(prepareCommit).not.toHaveBeenCalled(); + expect(fixture.store.snapshot().currentHead).toBeNull(); + }); + + it('rejects an oversized encoded profile bundle before signing, install, or commit', async () => { + const fixture = await producerFixture(); + const multiaddrs = Array.from({ length: 6_000 }, (_, index) => ( + `/dns4/profile-${index.toString().padStart(4, '0')}-${'a'.repeat(96)}.example/tcp/4001` + )); + const prepared = prepareAgentProfileV1({ + peerId: fixture.peerSigner.peerId, + publicKey: Buffer.from(fixture.peerSigner.publicKey, 'base64url').toString('base64'), + agentAddress: fixture.evmSigner.address, + name: 'Oversized profile fixture', + nodeRole: 'edge', + lastSeen: '2026-08-07T12:00:00.000Z', + skills: [], + multiaddrs, + }); + const peerSign = vi.fn(fixture.peerSigner.sign); + const evmSignMessage = vi.fn(fixture.evmSigner.signMessage); + const prepareCommit = vi.fn(fixture.store.prepareCommit.bind(fixture.store)); + const install = vi.fn(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: { ...fixture.peerSigner, sign: peerSign }, + evmSigner: { ...fixture.evmSigner, signMessage: evmSignMessage }, + store: { + snapshot: () => fixture.store.snapshot(), + resolveArtifact: (reference) => fixture.store.resolveArtifact(reference), + prepareCommit, + }, + fence: () => {}, + install, + }); + + await expect(produce( + producer, + prepared, + await publicationFor(prepared, fixture.evmSigner.address, '2026-08-07T12:00:00Z'), + )).rejects.toThrow(/profile bundle exceeds/); + expect(peerSign).not.toHaveBeenCalled(); + expect(evmSignMessage).not.toHaveBeenCalled(); + expect(prepareCommit).not.toHaveBeenCalled(); + expect(install).not.toHaveBeenCalled(); + expect(fixture.store.snapshot().currentHead).toBeNull(); + }); + + it('normalizes millisecond publication timestamps before signing the head', async () => { + const fixture = await producerFixture(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => {}, + install: () => {}, + }); + await produce(producer, fixture.prepared, { + ...fixture.publication, + issuedAt: '2026-08-07T12:00:00.123Z', + validUntil: '2026-08-08T12:00:00.987Z', + }); + + expect(fixture.store.snapshot().currentHead?.object).toMatchObject({ + issuedAt: '2026-08-07T12:00:00Z', + validUntil: '2026-08-08T12:00:00Z', + }); + }); + + it('rejects invalid publication timestamp scalars before signing or committing', async () => { + const fixture = await producerFixture(); + const peerSign = vi.fn(fixture.peerSigner.sign); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: { ...fixture.peerSigner, sign: peerSign }, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => {}, + install: () => {}, + }); + + await expect(produce(producer, fixture.prepared, { + ...fixture.publication, + issuedAt: '2026-02-30T12:00:00.000Z', + })).rejects.toThrow(/calendar-valid/); + expect(peerSign).not.toHaveBeenCalled(); + expect(fixture.store.snapshot().currentHead).toBeNull(); + }); + + it('rejects a seal whose author attestation does not recover the profile authority', async () => { + const fixture = await producerFixture(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => {}, + install: () => {}, + }); + const tampered = { + ...fixture.publication, + seal: { + ...fixture.publication.seal, + authorAttestationR: `0x${'22'.repeat(32)}`, + authorAttestationVS: `0x${'33'.repeat(32)}`, + }, + } as AgentProfilePublicationBindingV1; + await expect(produce(producer, fixture.prepared, tampered)) + .rejects.toThrow(/attestation does not recover/); + expect(fixture.store.snapshot().currentHead).toBeNull(); + }); + + it('rolls back when the peer signer returns an invalid profile-head signature', async () => { + const fixture = await producerFixture(); + const install = vi.fn(); + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: { ...fixture.peerSigner, sign: async () => new Uint8Array(64) }, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => {}, + install, + }); + + await expect(produce(producer, fixture.prepared, fixture.publication)) + .rejects.toThrow(/head signature verification failed/); + expect(install).not.toHaveBeenCalled(); + expect(fixture.store.snapshot().currentHead).toBeNull(); + }); + + it('rolls back when the peer signer returns an invalid inventory-root signature', async () => { + const fixture = await producerFixture(); + const install = vi.fn(); + let signatureNumber = 0; + const producer = createAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: { + ...fixture.peerSigner, + sign: async (message) => { + signatureNumber += 1; + return signatureNumber === 1 + ? fixture.peerSigner.sign(message) + : new Uint8Array(64); + }, + }, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => {}, + install, + }); + + await expect(produce(producer, fixture.prepared, fixture.publication)) + .rejects.toThrow(/inventory root signature verification failed/); + expect(install).not.toHaveBeenCalled(); + expect(fixture.store.snapshot().currentHead).toBeNull(); + }); + +}); diff --git a/packages/core/src/agent-profile-projection-schema-v1.ts b/packages/core/src/agent-profile-projection-schema-v1.ts index 0af37e7148..ec611e41dc 100644 --- a/packages/core/src/agent-profile-projection-schema-v1.ts +++ b/packages/core/src/agent-profile-projection-schema-v1.ts @@ -1,40 +1,19 @@ import { decodeWorkspaceEncryptionKey } from './crypto/workspace-encryption.js'; import { assertSafeIri, assertSafeRdfTerm, isSafeIri } from './sparql-safe.js'; import { - AGENT_PROFILE_LINK_PREDICATES_V1, + AGENT_PROFILE_SCHEMA_TERMS_V1, + agentProfilePredicatePolicyV1, + agentProfileSubjectPolicyV1, + classifyAgentProfileOwnedSubjectV1, +} from './agent-profile-schema-model-v1.js'; +import { assertDerivedAgentEncryptionSubjectV1, assertOwnedSubjectTableObjectV1, - classifyAgentProfileOwnedSubjectV1, - isAllowedAgentProfilePredicateV1, type AgentProfileHeadCommonV1, - type AgentProfileOwnedSubjectKindV1, type OwnedSubjectTableObjectV1, } from './system-record-objects-v1.js'; -const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; -const DKG = 'https://dkg.network/ontology#'; -const ERC8004 = 'https://eips.ethereum.org/erc-8004#'; -const PROV = 'http://www.w3.org/ns/prov#'; -const SKILL = 'https://dkg.origintrail.io/skill#'; - -const IRI_OBJECT_PREDICATES = new Set([ - RDF_TYPE, - ...Object.values(AGENT_PROFILE_LINK_PREDICATES_V1), - `${SKILL}skill`, - `${SKILL}pricing`, - `${DKG}revokedBy`, -]); -const PUBLIC_ENCRYPTION_KEY = `${DKG}publicEncryptionKey`; -const ALLOWED_TYPE_OBJECTS: Readonly< - Record> -> = Object.freeze({ - root: new Set([`${DKG}Agent`, `${DKG}CoreNode`, `${DKG}EdgeNode`]), - capability: new Set([`${ERC8004}Capability`]), - offering: new Set([`${SKILL}SkillOffering`]), - registration: new Set([`${PROV}Activity`]), - hosting: new Set([`${SKILL}HostingProfile`]), - x25519: new Set(), -}); +const T = AGENT_PROFILE_SCHEMA_TERMS_V1; export interface AgentProfileProjectionQuadV1 { readonly subject: string; @@ -69,13 +48,13 @@ export function agentProfileIdentityFactsV1( ): AgentProfileIdentityFactsV1 { return Object.freeze({ rootSubject: input.rootSubject, - peerId: Object.freeze({ predicate: `${DKG}peerId`, object: `"${input.peerId}"` }), + peerId: Object.freeze({ predicate: T.dkgPeerId, object: `"${input.peerId}"` }), ...(input.publicKey === undefined ? {} : { - publicKey: Object.freeze({ predicate: `${DKG}publicKey`, object: `"${input.publicKey}"` }), + publicKey: Object.freeze({ predicate: T.dkgPublicKey, object: `"${input.publicKey}"` }), }), ...(input.agentAddress === undefined ? {} : { agentAddress: Object.freeze({ - predicate: `${DKG}agentAddress`, + predicate: T.dkgAgentAddress, object: `"${input.agentAddress}"`, }), }), @@ -133,7 +112,10 @@ export function assertAgentProfileProjectionSchemaV1( throw new Error(`profile projection quad ${index} has an unowned subject`); } const subjectKind = classifyAgentProfileOwnedSubjectV1(rootSubject, quad.subject); - if (subjectKind === null || !isAllowedAgentProfilePredicateV1(subjectKind, quad.predicate)) { + const predicatePolicy = subjectKind === null + ? undefined + : agentProfilePredicatePolicyV1(subjectKind, quad.predicate); + if (subjectKind === null || predicatePolicy === undefined) { throw new Error(`profile projection quad ${index} uses a disallowed profile predicate`); } const objectIsLiteral = quad.object.startsWith('"'); @@ -143,25 +125,40 @@ export function assertAgentProfileProjectionSchemaV1( } else assertSafeIri(quad.object); if (quad.graph !== '') throw new Error('profile projections must be graphless'); seenSubjects.add(quad.subject); - if (IRI_OBJECT_PREDICATES.has(quad.predicate) === objectIsLiteral) { - throw new Error('profile projection predicate has an invalid object term kind'); - } - if (quad.predicate === RDF_TYPE && !ALLOWED_TYPE_OBJECTS[subjectKind].has(quad.object)) { - throw new Error('profile projection rdf:type object is outside the frozen profile schema'); - } - if (quad.subject === rootSubject) { - const linkKind = Object.entries(AGENT_PROFILE_LINK_PREDICATES_V1) - .find(([, predicate]) => predicate === quad.predicate)?.[0] as - | Exclude - | undefined; - if (linkKind !== undefined) { - if (objectIsLiteral || !ownedSubjects.has(quad.object) - || classifyAgentProfileOwnedSubjectV1(rootSubject, quad.object) !== linkKind) { + switch (predicatePolicy.objectPolicy) { + case 'literal': + if (!objectIsLiteral) { + throw new Error('profile projection predicate has an invalid object term kind'); + } + break; + case 'iri': + if (objectIsLiteral) { + throw new Error('profile projection predicate has an invalid object term kind'); + } + break; + case 'allowed-iri': + if (objectIsLiteral) { + throw new Error('profile projection predicate has an invalid object term kind'); + } + if (!predicatePolicy.allowedObjects.includes(quad.object)) { + throw new Error('profile projection rdf:type object is outside the frozen profile schema'); + } + break; + case 'owned-subject-link': + if (objectIsLiteral) { + throw new Error('profile projection predicate has an invalid object term kind'); + } + if (!ownedSubjects.has(quad.object) + || classifyAgentProfileOwnedSubjectV1(rootSubject, quad.object) + !== predicatePolicy.linkTargetKind) { throw new Error('profile link does not target its exact derived-subject kind'); } linked.add(quad.object); - } - if (quad.predicate === PUBLIC_ENCRYPTION_KEY) { + break; + case 'workspace-public-key': { + if (!objectIsLiteral) { + throw new Error('profile projection predicate has an invalid object term kind'); + } const match = /^"([A-Za-z0-9_-]{43})"$/.exec(quad.object); if (match === null) throw new Error('profile public encryption key is not canonical'); try { @@ -169,11 +166,20 @@ export function assertAgentProfileProjectionSchemaV1( } catch (cause) { throw new Error('profile public encryption key is invalid', { cause }); } + break; + } + case 'profile-root-iri': + if (objectIsLiteral) { + throw new Error('profile projection predicate has an invalid object term kind'); + } + if (quad.object !== rootSubject) { + throw new Error('x25519 revocation does not bind the profile root'); + } + break; + default: { + const unsupported: never = predicatePolicy; + throw new TypeError(`unsupported profile predicate policy: ${String(unsupported)}`); } - } - if (subjectKind === 'x25519' && quad.predicate === `${DKG}revokedBy` - && quad.object !== rootSubject) { - throw new Error('x25519 revocation does not bind the profile root'); } } for (const subject of ownedSubjectTable) { @@ -181,9 +187,13 @@ export function assertAgentProfileProjectionSchemaV1( throw new Error('owned-subject table contains a subject absent from the projection'); } const kind = classifyAgentProfileOwnedSubjectV1(rootSubject, subject); - if (kind === 'capability' || kind === 'offering' || kind === 'registration' || kind === 'hosting') { + if (kind === null) { + throw new Error('owned-subject table contains an invalid subject'); + } + const subjectPolicy = agentProfileSubjectPolicyV1(kind); + if (subjectPolicy.rootLinkPredicate !== undefined) { if (!linked.has(subject)) throw new Error('derived profile subject is not linked from the root'); - } else if (kind === 'x25519') { + } else if (subjectPolicy.derivation === 'workspace-public-key') { const derived = publicKeys.some((key) => { try { assertDerivedAgentEncryptionSubjectV1(rootSubject, subject, key); diff --git a/packages/core/src/agent-profile-schema-model-v1.ts b/packages/core/src/agent-profile-schema-model-v1.ts new file mode 100644 index 0000000000..38107dca8b --- /dev/null +++ b/packages/core/src/agent-profile-schema-model-v1.ts @@ -0,0 +1,343 @@ +import { SYSTEM_RECORD_OBJECT_CAPS_V1 } from './system-record-limits-v1.js'; + +export type AgentProfileOwnedSubjectKindV1 = + | 'root' + | 'capability' + | 'offering' + | 'registration' + | 'hosting' + | 'x25519'; + +export type AgentProfileLinkedSubjectKindV1 = Exclude< + AgentProfileOwnedSubjectKindV1, + 'root' | 'x25519' +>; +export type AgentProfileIndexedSubjectKindV1 = 'capability' | 'offering'; +export type AgentProfileExactLinkedSubjectKindV1 = 'registration' | 'hosting'; + +export type AgentProfilePredicatePolicyV1 = + | Readonly<{ readonly predicate: string; readonly objectPolicy: 'literal' }> + | Readonly<{ readonly predicate: string; readonly objectPolicy: 'iri' }> + | Readonly<{ + readonly predicate: string; + readonly objectPolicy: 'allowed-iri'; + readonly allowedObjects: readonly string[]; + }> + | Readonly<{ + readonly predicate: string; + readonly objectPolicy: 'owned-subject-link'; + readonly linkTargetKind: AgentProfileLinkedSubjectKindV1; + }> + | Readonly<{ + readonly predicate: string; + readonly objectPolicy: 'profile-root-iri'; + }> + | Readonly<{ + readonly predicate: string; + readonly objectPolicy: 'workspace-public-key'; + }>; + +export type AgentProfileSubjectShapeV1 = + | Readonly<{ readonly type: 'root' }> + | Readonly<{ readonly type: 'indexed-genid'; readonly prefix: string }> + | Readonly<{ readonly type: 'exact-genid'; readonly suffix: string }> + | Readonly<{ + readonly type: 'hex-fragment'; + readonly prefix: string; + readonly hexLength: number; + }>; + +export interface AgentProfileSubjectPolicyV1 { + readonly kind: AgentProfileOwnedSubjectKindV1; + readonly subjectShape: AgentProfileSubjectShapeV1; + readonly predicates: readonly Readonly[]; + readonly rootLinkPredicate?: string; + readonly derivation?: 'workspace-public-key'; +} + +type AgentProfileSubjectPolicyForV1 = Readonly< + Omit & { readonly kind: Kind } +>; + +type AgentProfileSubjectPolicyTableV1 = { + readonly [Kind in AgentProfileOwnedSubjectKindV1]: AgentProfileSubjectPolicyForV1; +}; + +const RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; +const SCHEMA = 'https://schema.org/'; +const DKG = 'https://dkg.network/ontology#'; +const ERC8004 = 'https://eips.ethereum.org/erc-8004#'; +const PROV = 'http://www.w3.org/ns/prov#'; +const SKILL = 'https://dkg.origintrail.io/skill#'; + +/** Named V1 terms used by both profile authors and verifiers. */ +export const AGENT_PROFILE_SCHEMA_TERMS_V1 = Object.freeze({ + skillNamespace: SKILL, + rdfType: `${RDF}type`, + schemaName: `${SCHEMA}name`, + schemaDescription: `${SCHEMA}description`, + dkgAgent: `${DKG}Agent`, + dkgCoreNode: `${DKG}CoreNode`, + dkgEdgeNode: `${DKG}EdgeNode`, + dkgPeerId: `${DKG}peerId`, + dkgNodeRole: `${DKG}nodeRole`, + dkgPublicKey: `${DKG}publicKey`, + dkgRelayAddress: `${DKG}relayAddress`, + dkgAgentAddress: `${DKG}agentAddress`, + dkgMultiaddr: `${DKG}multiaddr`, + dkgLastSeen: `${DKG}lastSeen`, + dkgPublicEncryptionKey: `${DKG}publicEncryptionKey`, + dkgEncryptionKeyAlgorithm: `${DKG}encryptionKeyAlgorithm`, + dkgEncryptionKeyProof: `${DKG}encryptionKeyProof`, + dkgRevokedAt: `${DKG}revokedAt`, + dkgRevokedBy: `${DKG}revokedBy`, + dkgEncryptionKeyRevocationProof: `${DKG}encryptionKeyRevocationProof`, + erc8004Capabilities: `${ERC8004}capabilities`, + erc8004Capability: `${ERC8004}Capability`, + provWasGeneratedBy: `${PROV}wasGeneratedBy`, + provActivity: `${PROV}Activity`, + provAtTime: `${PROV}atTime`, + skillFramework: `${SKILL}framework`, + skillOffersSkill: `${SKILL}offersSkill`, + skillSkillOffering: `${SKILL}SkillOffering`, + skillSkill: `${SKILL}skill`, + skillPricePerCall: `${SKILL}pricePerCall`, + skillCurrency: `${SKILL}currency`, + skillSuccessRate: `${SKILL}successRate`, + skillPricing: `${SKILL}pricing`, + skillHostingProfile: `${SKILL}hostingProfile`, + skillHostingProfileType: `${SKILL}HostingProfile`, + skillContextGraphsServed: `${SKILL}contextGraphsServed`, + skillParanetsServed: `${SKILL}paranetsServed`, +} as const); + +const T = AGENT_PROFILE_SCHEMA_TERMS_V1; + +export const AGENT_PROFILE_LINK_PREDICATES_V1 = Object.freeze({ + capability: T.erc8004Capabilities, + offering: T.skillOffersSkill, + registration: T.provWasGeneratedBy, + hosting: T.skillHostingProfile, +} as const); + +function literal(predicate: string): Readonly { + return Object.freeze({ predicate, objectPolicy: 'literal' as const }); +} + +function iri(predicate: string): Readonly { + return Object.freeze({ predicate, objectPolicy: 'iri' as const }); +} + +function allowedIri( + predicate: string, + allowedObjects: readonly string[], +): Readonly { + return Object.freeze({ + predicate, + objectPolicy: 'allowed-iri' as const, + allowedObjects: Object.freeze([...allowedObjects]), + }); +} + +function ownedSubjectLink( + predicate: string, + linkTargetKind: AgentProfileLinkedSubjectKindV1, +): Readonly { + return Object.freeze({ + predicate, + objectPolicy: 'owned-subject-link' as const, + linkTargetKind, + }); +} + +function profileRootIri(predicate: string): Readonly { + return Object.freeze({ predicate, objectPolicy: 'profile-root-iri' as const }); +} + +function workspacePublicKey(predicate: string): Readonly { + return Object.freeze({ predicate, objectPolicy: 'workspace-public-key' as const }); +} + +function subjectPolicy( + policy: AgentProfileSubjectPolicyForV1, +): AgentProfileSubjectPolicyForV1 { + return Object.freeze({ + ...policy, + subjectShape: Object.freeze({ ...policy.subjectShape }), + predicates: Object.freeze([...policy.predicates]), + }); +} + +const SUBJECT_POLICY_BY_KIND = Object.freeze({ + root: subjectPolicy({ + kind: 'root', + subjectShape: { type: 'root' }, + predicates: [ + allowedIri(T.rdfType, [T.dkgAgent, T.dkgCoreNode, T.dkgEdgeNode]), + literal(T.schemaName), + literal(T.schemaDescription), + literal(T.dkgPeerId), + literal(T.dkgNodeRole), + literal(T.dkgPublicKey), + literal(T.dkgRelayAddress), + literal(T.dkgAgentAddress), + literal(T.dkgMultiaddr), + literal(T.dkgLastSeen), + workspacePublicKey(T.dkgPublicEncryptionKey), + literal(T.dkgEncryptionKeyAlgorithm), + literal(T.dkgEncryptionKeyProof), + literal(T.skillFramework), + ownedSubjectLink(T.erc8004Capabilities, 'capability'), + ownedSubjectLink(T.skillOffersSkill, 'offering'), + ownedSubjectLink(T.provWasGeneratedBy, 'registration'), + ownedSubjectLink(T.skillHostingProfile, 'hosting'), + ], + }), + capability: subjectPolicy({ + kind: 'capability', + subjectShape: { type: 'indexed-genid', prefix: 'cap' }, + rootLinkPredicate: T.erc8004Capabilities, + predicates: [ + allowedIri(T.rdfType, [T.erc8004Capability]), + literal(T.schemaName), + ], + }), + offering: subjectPolicy({ + kind: 'offering', + subjectShape: { type: 'indexed-genid', prefix: 'offering' }, + rootLinkPredicate: T.skillOffersSkill, + predicates: [ + allowedIri(T.rdfType, [T.skillSkillOffering]), + iri(T.skillSkill), + literal(T.skillPricePerCall), + literal(T.skillCurrency), + literal(T.skillSuccessRate), + iri(T.skillPricing), + ], + }), + registration: subjectPolicy({ + kind: 'registration', + subjectShape: { type: 'exact-genid', suffix: 'registration' }, + rootLinkPredicate: T.provWasGeneratedBy, + predicates: [ + allowedIri(T.rdfType, [T.provActivity]), + literal(T.provAtTime), + ], + }), + hosting: subjectPolicy({ + kind: 'hosting', + subjectShape: { type: 'exact-genid', suffix: 'hosting' }, + rootLinkPredicate: T.skillHostingProfile, + predicates: [ + allowedIri(T.rdfType, [T.skillHostingProfileType]), + literal(T.skillContextGraphsServed), + literal(T.skillParanetsServed), + ], + }), + x25519: subjectPolicy({ + kind: 'x25519', + subjectShape: { type: 'hex-fragment', prefix: 'x25519-', hexLength: 32 }, + derivation: 'workspace-public-key', + predicates: [ + literal(T.dkgRevokedAt), + profileRootIri(T.dkgRevokedBy), + literal(T.dkgEncryptionKeyRevocationProof), + ], + }), +} satisfies AgentProfileSubjectPolicyTableV1); + +const SUBJECT_POLICIES = Object.freeze(Object.values(SUBJECT_POLICY_BY_KIND)); + +const ROOT_PATTERN = /^did:dkg:agent:(0x[0-9a-f]{40})$/; +const PREDICATE_POLICY_BY_KIND = new Map( + SUBJECT_POLICIES.map((policy) => [ + policy.kind, + new Map(policy.predicates.map((predicate) => [predicate.predicate, predicate])), + ]), +); + +export function matchAgentProfileRootAddressV1(value: string): string | null { + return ROOT_PATTERN.exec(value)?.[1] ?? null; +} + +export function agentProfileSubjectPolicyV1( + kind: AgentProfileOwnedSubjectKindV1, +): Readonly { + return SUBJECT_POLICY_BY_KIND[kind]; +} + +export function agentProfilePredicatePolicyV1( + kind: AgentProfileOwnedSubjectKindV1, + predicate: string, +): Readonly | undefined { + return PREDICATE_POLICY_BY_KIND.get(kind)?.get(predicate); +} + +export function deriveAgentProfileOwnedSubjectV1( + rootSubject: string, + kind: AgentProfileIndexedSubjectKindV1, + ordinal: number, +): string; +export function deriveAgentProfileOwnedSubjectV1( + rootSubject: string, + kind: AgentProfileExactLinkedSubjectKindV1, +): string; +export function deriveAgentProfileOwnedSubjectV1( + rootSubject: string, + kind: AgentProfileLinkedSubjectKindV1, + ordinal?: number, +): string { + const shape = agentProfileSubjectPolicyV1(kind).subjectShape; + if (shape.type === 'indexed-genid') { + if (!Number.isSafeInteger(ordinal) || ordinal === undefined || ordinal < 1) { + throw new RangeError(`profile ${kind} subject ordinal must be a positive safe integer`); + } + return `${rootSubject}/.well-known/genid/${shape.prefix}${ordinal}`; + } + if (shape.type === 'exact-genid') { + if (ordinal !== undefined) throw new TypeError(`profile ${kind} subject does not use an ordinal`); + return `${rootSubject}/.well-known/genid/${shape.suffix}`; + } + throw new TypeError(`profile ${kind} subject does not use a derived linked-subject shape`); +} + +export function classifyAgentProfileOwnedSubjectV1( + rootSubject: string, + subject: string, +): AgentProfileOwnedSubjectKindV1 | null { + if (typeof rootSubject !== 'string' || typeof subject !== 'string' + || rootSubject.length > SYSTEM_RECORD_OBJECT_CAPS_V1['owned-subject-table'] + || subject.length > SYSTEM_RECORD_OBJECT_CAPS_V1['owned-subject-table'] + || matchAgentProfileRootAddressV1(rootSubject) === null) return null; + const genidPrefix = `${rootSubject}/.well-known/genid/`; + const fragmentPrefix = `${rootSubject}#`; + for (const policy of SUBJECT_POLICIES) { + const shape = policy.subjectShape; + if (shape.type === 'root') { + if (subject === rootSubject) return policy.kind; + continue; + } + if (shape.type === 'indexed-genid') { + if (!subject.startsWith(genidPrefix)) continue; + const suffix = subject.slice(genidPrefix.length); + if (suffix.startsWith(shape.prefix) + && /^[1-9][0-9]*$/.test(suffix.slice(shape.prefix.length))) return policy.kind; + continue; + } + if (shape.type === 'exact-genid') { + if (subject === `${genidPrefix}${shape.suffix}`) return policy.kind; + continue; + } + if (!subject.startsWith(`${fragmentPrefix}${shape.prefix}`)) continue; + const suffix = subject.slice(fragmentPrefix.length + shape.prefix.length); + if (suffix.length === shape.hexLength && /^[0-9a-f]+$/.test(suffix)) return policy.kind; + } + return null; +} + +export function isAllowedAgentProfilePredicateV1( + kind: AgentProfileOwnedSubjectKindV1, + predicate: string, +): boolean { + return agentProfilePredicatePolicyV1(kind, predicate) !== undefined; +} diff --git a/packages/core/src/system-record-objects-v1.ts b/packages/core/src/system-record-objects-v1.ts index 4237cbdc46..afd7a9b872 100644 --- a/packages/core/src/system-record-objects-v1.ts +++ b/packages/core/src/system-record-objects-v1.ts @@ -21,6 +21,10 @@ import { import { keccak256 } from './crypto/keccak.js'; import { workspaceAgentEncryptionKeyId } from './crypto/workspace-encryption.js'; import { parseDeterministicKnowledgeAssetUal } from './ka-content-scope.js'; +import { + classifyAgentProfileOwnedSubjectV1, + matchAgentProfileRootAddressV1, +} from './agent-profile-schema-model-v1.js'; import { assertCanonicalSystemRecordPeerIdV1, copyBoundedSystemRecordBytesV1, @@ -100,7 +104,6 @@ import { const UTF8 = new TextEncoder(); const RFC3339_SECONDS = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z$/; -const AGENT_ROOT = /^did:dkg:agent:(0x[0-9a-f]{40})$/; const REQUEST_RECORD_KIND = SYSTEM_RECORD_KIND_V1; export type CanonicalRfc3339SecondsV1 = string & { readonly __rfc3339SecondsV1: true }; @@ -112,6 +115,19 @@ export { SystemRecordObjectErrorV1, }; export type { SystemRecordObjectErrorCodeV1, SystemRecordPeerPublicKeyV1 }; +export { + AGENT_PROFILE_LINK_PREDICATES_V1, + AGENT_PROFILE_SCHEMA_TERMS_V1, + classifyAgentProfileOwnedSubjectV1, + deriveAgentProfileOwnedSubjectV1, + isAllowedAgentProfilePredicateV1, +} from './agent-profile-schema-model-v1.js'; +export type { + AgentProfileExactLinkedSubjectKindV1, + AgentProfileIndexedSubjectKindV1, + AgentProfileLinkedSubjectKindV1, + AgentProfileOwnedSubjectKindV1, +} from './agent-profile-schema-model-v1.js'; export interface AgentProfileHeadCommonV1 { readonly objectType: 'agent-profile-head'; @@ -295,10 +311,10 @@ export function assertSystemRecordPeerBindingV1( } export function assertAgentRootV1(value: unknown, issuer?: string): asserts value is string { - if (typeof value !== 'string' || !AGENT_ROOT.test(value)) { + if (typeof value !== 'string' || matchAgentProfileRootAddressV1(value) === null) { fail('system-record-scalar', 'agent root must be a canonical did:dkg:agent address'); } - const rootAddress = AGENT_ROOT.exec(value)![1]; + const rootAddress = matchAgentProfileRootAddressV1(value)!; try { assertCanonicalEvmAddress(rootAddress, 'agent root address'); } catch (cause) { @@ -794,78 +810,6 @@ function validateConflictEvidence(value: unknown): AgentProfileConflictEvidenceV return Object.freeze({ ...evidence, entries: Object.freeze(entries) }) as unknown as AgentProfileConflictEvidenceV1; } -export type AgentProfileOwnedSubjectKindV1 = - | 'root' - | 'capability' - | 'offering' - | 'registration' - | 'hosting' - | 'x25519'; - -const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; -const SCHEMA = 'https://schema.org/'; -const DKG = 'https://dkg.network/ontology#'; -const ERC8004 = 'https://eips.ethereum.org/erc-8004#'; -const PROV = 'http://www.w3.org/ns/prov#'; -const SKILL = 'https://dkg.origintrail.io/skill#'; - -export const AGENT_PROFILE_LINK_PREDICATES_V1 = Object.freeze({ - capability: `${ERC8004}capabilities`, - offering: `${SKILL}offersSkill`, - registration: `${PROV}wasGeneratedBy`, - hosting: `${SKILL}hostingProfile`, -} as const); - -const AGENT_PROFILE_PREDICATES_V1: Readonly>> = { - root: new Set([ - RDF_TYPE, `${SCHEMA}name`, `${SCHEMA}description`, `${DKG}peerId`, `${DKG}nodeRole`, - `${DKG}publicKey`, `${DKG}relayAddress`, `${DKG}agentAddress`, `${DKG}multiaddr`, - `${DKG}lastSeen`, `${DKG}publicEncryptionKey`, `${DKG}encryptionKeyAlgorithm`, - `${DKG}encryptionKeyProof`, `${SKILL}framework`, - ...Object.values(AGENT_PROFILE_LINK_PREDICATES_V1), - ]), - capability: new Set([RDF_TYPE, `${SCHEMA}name`]), - offering: new Set([ - RDF_TYPE, `${SKILL}skill`, `${SKILL}pricePerCall`, `${SKILL}currency`, - `${SKILL}successRate`, `${SKILL}pricing`, - ]), - registration: new Set([RDF_TYPE, `${PROV}atTime`]), - hosting: new Set([RDF_TYPE, `${SKILL}contextGraphsServed`, `${SKILL}paranetsServed`]), - x25519: new Set([`${DKG}revokedAt`, `${DKG}revokedBy`, `${DKG}encryptionKeyRevocationProof`]), -}; - -export function classifyAgentProfileOwnedSubjectV1( - rootSubject: string, - subject: string, -): AgentProfileOwnedSubjectKindV1 | null { - if (typeof rootSubject !== 'string' || typeof subject !== 'string' - || rootSubject.length > SYSTEM_RECORD_OBJECT_CAPS_V1['owned-subject-table'] - || subject.length > SYSTEM_RECORD_OBJECT_CAPS_V1['owned-subject-table']) return null; - if (!AGENT_ROOT.test(rootSubject)) return null; - if (subject === rootSubject) return 'root'; - const wellKnown = `${rootSubject}/.well-known/genid/`; - if (subject.startsWith(wellKnown)) { - const suffix = subject.slice(wellKnown.length); - if (/^cap[1-9][0-9]*$/.test(suffix)) return 'capability'; - if (/^offering[1-9][0-9]*$/.test(suffix)) return 'offering'; - if (suffix === 'registration') return 'registration'; - if (suffix === 'hosting') return 'hosting'; - return null; - } - const encryptionPrefix = `${rootSubject}#x25519-`; - return subject.startsWith(encryptionPrefix) - && /^[0-9a-f]{32}$/.test(subject.slice(encryptionPrefix.length)) - ? 'x25519' - : null; -} - -export function isAllowedAgentProfilePredicateV1( - kind: AgentProfileOwnedSubjectKindV1, - predicate: string, -): boolean { - return AGENT_PROFILE_PREDICATES_V1[kind].has(predicate); -} - export function assertDerivedAgentEncryptionSubjectV1( rootSubject: string, subject: string, @@ -881,7 +825,7 @@ export function assertDerivedAgentEncryptionSubjectV1( if (ownedPublicKey.byteLength !== 32) { fail('system-record-binding', 'x25519 public key must contain exactly 32 bytes'); } - const address = AGENT_ROOT.exec(rootSubject)![1]; + const address = matchAgentProfileRootAddressV1(rootSubject)!; const expected = workspaceAgentEncryptionKeyId(address, ownedPublicKey); if (subject !== expected) { fail('system-record-binding', 'x25519 owned subject is not derived from its root and public key'); diff --git a/packages/core/test/system-record-package-export-v1.mjs b/packages/core/test/system-record-package-export-v1.mjs index f3f60f3a2b..8b6c78d139 100644 --- a/packages/core/test/system-record-package-export-v1.mjs +++ b/packages/core/test/system-record-package-export-v1.mjs @@ -2,6 +2,7 @@ const api = await import('@origintrail-official/dkg-core/system-record-v1'); const representativeExports = [ 'SYSTEM_RECORD_KIND_V1', + 'AGENT_PROFILE_SCHEMA_TERMS_V1', 'computeAgentProfileHeadObjectDigestV1', 'parseCanonicalSystemRecordAppliedStateV1', 'computeSystemRecordRootDescriptorDigestV1', diff --git a/packages/core/test/system-record-policy-helpers-v1.test.ts b/packages/core/test/system-record-policy-helpers-v1.test.ts index a0697ab1f7..b94e4cad3b 100644 --- a/packages/core/test/system-record-policy-helpers-v1.test.ts +++ b/packages/core/test/system-record-policy-helpers-v1.test.ts @@ -2,11 +2,18 @@ import { describe, expect, it } from 'vitest'; import { AGENT_PROFILE_LINK_PREDICATES_V1, + assertAgentProfileProjectionSchemaV1, + classifyAgentProfileOwnedSubjectV1, + deriveAgentProfileOwnedSubjectV1, evaluateAuthorityTransitionConflictV1, isAllowedAgentProfilePredicateV1, type AgentProfileAuthorityTransitionV1, type AgentProfileOwnedSubjectKindV1, } from '../src/system-record-v1.js'; +import { + agentProfilePredicatePolicyV1, + agentProfileSubjectPolicyV1, +} from '../src/agent-profile-schema-model-v1.js'; const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; const RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; @@ -73,6 +80,23 @@ const UNLISTED_SAME_NAMESPACE_PREDICATES_V1 = [ const PROFILE_SUBJECT_KINDS_V1 = Object.keys( EXPECTED_ALLOWED_PROFILE_PREDICATES_V1, ) as AgentProfileOwnedSubjectKindV1[]; +const EXPECTED_IRI_OBJECT_PREDICATES_V1 = new Set([ + RDF_TYPE, + ...Object.values(EXPECTED_AGENT_PROFILE_LINK_PREDICATES_V1), + `${SKILL}skill`, + `${SKILL}pricing`, + `${DKG}revokedBy`, +]); +const EXPECTED_LINK_OBJECT_PREDICATES_V1 = new Set( + Object.values(EXPECTED_AGENT_PROFILE_LINK_PREDICATES_V1), +); +const EXPECTED_ALLOWED_TYPE_OBJECTS_V1 = { + root: [`${DKG}Agent`, `${DKG}CoreNode`, `${DKG}EdgeNode`], + capability: [`${ERC8004}Capability`], + offering: [`${SKILL}SkillOffering`], + registration: [`${PROV}Activity`], + hosting: [`${SKILL}HostingProfile`], +} as const; const FOREIGN_PEER = { peerId: '12D3KooWHwCJEQ7p5idnD7iQAWyCJHEW7rngKQiXCnEfGef69SV4', @@ -154,4 +178,128 @@ describe('system-record V1 public policy helpers', () => { expect(AGENT_PROFILE_LINK_PREDICATES_V1) .toEqual(EXPECTED_AGENT_PROFILE_LINK_PREDICATES_V1); }); + + it('keeps one immutable internal policy for subject, predicate, term, type, and link rules', () => { + for (const kind of PROFILE_SUBJECT_KINDS_V1) { + const subjectPolicy = agentProfileSubjectPolicyV1(kind); + expect(Object.isFrozen(subjectPolicy), kind).toBe(true); + expect(Object.isFrozen(subjectPolicy.subjectShape), kind).toBe(true); + expect(Object.isFrozen(subjectPolicy.predicates), kind).toBe(true); + expect(subjectPolicy.predicates.map(({ predicate }) => predicate), kind) + .toEqual(EXPECTED_ALLOWED_PROFILE_PREDICATES_V1[kind]); + for (const predicate of subjectPolicy.predicates) { + expect(Object.isFrozen(predicate), `${kind}: ${predicate.predicate}`).toBe(true); + const expectedObjectPolicy = predicate.predicate === RDF_TYPE + ? 'allowed-iri' + : EXPECTED_LINK_OBJECT_PREDICATES_V1.has(predicate.predicate) + ? 'owned-subject-link' + : predicate.predicate === `${DKG}revokedBy` + ? 'profile-root-iri' + : predicate.predicate === `${DKG}publicEncryptionKey` + ? 'workspace-public-key' + : EXPECTED_IRI_OBJECT_PREDICATES_V1.has(predicate.predicate) + ? 'iri' + : 'literal'; + expect(predicate.objectPolicy, `${kind}: ${predicate.predicate}`) + .toBe(expectedObjectPolicy); + expect(agentProfilePredicatePolicyV1(kind, predicate.predicate)).toBe(predicate); + } + } + + for (const [kind, objects] of Object.entries(EXPECTED_ALLOWED_TYPE_OBJECTS_V1)) { + const typePolicy = agentProfilePredicatePolicyV1( + kind as AgentProfileOwnedSubjectKindV1, + RDF_TYPE, + ); + expect(typePolicy?.objectPolicy, kind).toBe('allowed-iri'); + if (typePolicy?.objectPolicy !== 'allowed-iri') throw new Error('expected type policy'); + expect(typePolicy.allowedObjects, kind).toEqual(objects); + expect(Object.isFrozen(typePolicy.allowedObjects), kind).toBe(true); + } + const capabilityLink = agentProfilePredicatePolicyV1('root', `${ERC8004}capabilities`); + expect(capabilityLink?.objectPolicy).toBe('owned-subject-link'); + if (capabilityLink?.objectPolicy !== 'owned-subject-link') { + throw new Error('expected capability link policy'); + } + expect(capabilityLink.linkTargetKind).toBe('capability'); + expect(agentProfilePredicatePolicyV1('x25519', `${DKG}revokedBy`)?.objectPolicy) + .toBe('profile-root-iri'); + expect(agentProfilePredicatePolicyV1('root', `${DKG}publicEncryptionKey`)?.objectPolicy) + .toBe('workspace-public-key'); + const root = `did:dkg:agent:0x${'11'.repeat(20)}`; + expect(deriveAgentProfileOwnedSubjectV1(root, 'capability', 2)) + .toBe(`${root}/.well-known/genid/cap2`); + expect(deriveAgentProfileOwnedSubjectV1(root, 'offering', 3)) + .toBe(`${root}/.well-known/genid/offering3`); + expect(deriveAgentProfileOwnedSubjectV1(root, 'registration')) + .toBe(`${root}/.well-known/genid/registration`); + expect(deriveAgentProfileOwnedSubjectV1(root, 'hosting')) + .toBe(`${root}/.well-known/genid/hosting`); + const x25519 = `${root}#x25519-${'a'.repeat(32)}`; + expect(classifyAgentProfileOwnedSubjectV1(root, x25519)).toBe('x25519'); + const uncheckedDerive = deriveAgentProfileOwnedSubjectV1 as unknown as ( + rootSubject: string, + kind: 'capability' | 'offering' | 'registration' | 'hosting', + ordinal?: number, + ) => string; + expect(() => uncheckedDerive(root, 'offering', 0)).toThrow(/positive/); + expect(() => uncheckedDerive(root, 'registration', 1)).toThrow(/ordinal/); + }); + + it('rejects literal objects for generic IRI predicates', () => { + const root = `did:dkg:agent:0x${'11'.repeat(20)}`; + const offering = deriveAgentProfileOwnedSubjectV1(root, 'offering', 1); + expect(() => assertAgentProfileProjectionSchemaV1( + root, + [root, offering], + [ + { subject: root, predicate: RDF_TYPE, object: `${DKG}Agent`, graph: '' }, + { + subject: root, + predicate: `${SKILL}offersSkill`, + object: offering, + graph: '', + }, + { + subject: offering, + predicate: RDF_TYPE, + object: `${SKILL}SkillOffering`, + graph: '', + }, + { + subject: offering, + predicate: `${SKILL}skill`, + object: '"ImageAnalysis"', + graph: '', + }, + ], + )).toThrow(/invalid object term kind/); + }); + + it('enforces workspace-key and revocation-root object policies in the validator', () => { + const root = `did:dkg:agent:0x${'11'.repeat(20)}`; + expect(() => assertAgentProfileProjectionSchemaV1( + root, + [root], + [{ + subject: root, + predicate: `${DKG}publicEncryptionKey`, + object: '"not-a-canonical-workspace-key"', + graph: '', + }], + )).toThrow(/public encryption key is not canonical/); + + const x25519 = `${root}#x25519-${'a'.repeat(32)}`; + const wrongRoot = `did:dkg:agent:0x${'22'.repeat(20)}`; + expect(() => assertAgentProfileProjectionSchemaV1( + root, + [root, x25519], + [{ + subject: x25519, + predicate: `${DKG}revokedBy`, + object: wrongRoot, + graph: '', + }], + )).toThrow(/revocation does not bind the profile root/); + }); });