Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,17 @@ cannot be updated or deleted. A durable supersession row drives post-commit
cleanup, and object storage bytes are deleted only after an exact
organization/reference query proves that no persisted owner still pins them.

Four exact parent-owned capabilities isolate the Skill artifact lifecycle.
`assetregistry::skill-package` accepts canonical validated uploads;
`assetregistry::skill-delivery` returns authorized release facts and content
without a storage locator; `assetregistry::skill-cleanup` exposes only the
Worker batch trigger and immutable summary; and
`assetregistry::skill-storage` is limited to exact parent persistence,
delivery, cleanup, and MinIO consumers. The parent owns storage writes,
compensation, reference persistence, supersession retry state, cleanup, and
storage opening. Skill package semantics never receive or publish the stored
object key.

The browser reaches that same lifecycle through Scratch authoring, bounded
`SKILL.md`/ZIP/folder upload, or GitHub import; each path ends at an ordinary
private Draft in the Assets Governance workspace. GitHub preview and eligible
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.orgmemory.api;

import static org.junit.jupiter.api.Assertions.assertEquals;

import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
import java.util.Set;
import java.util.TreeSet;
import org.junit.jupiter.api.Test;

class SkillCapabilityBoundaryTests {

private static final Set<String> PARENT_CAPABILITY_PACKAGES = Set.of(
"com.orgmemory.core.assetregistry.skillpackage.",
"com.orgmemory.core.assetregistry.skilldelivery.",
"com.orgmemory.core.assetregistry.skillcleanup.",
"com.orgmemory.core.assetregistry.skillstorage.");

@Test
void apiDoesNotImportParentSkillCapabilities() {
var dependencies = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages("com.orgmemory.api")
.stream()
.flatMap(type -> type.getDirectDependenciesFromSelf().stream())
.map(dependency -> dependency.getTargetClass().getName())
.filter(name -> PARENT_CAPABILITY_PACKAGES.stream()
.anyMatch(name::startsWith))
.collect(TreeSet::new, Set::add, Set::addAll);

assertEquals(Set.of(), dependencies);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
import com.orgmemory.core.assetregistry.prompt.PromptEvaluationResult;
import com.orgmemory.core.assetregistry.prompt.PromptExecutionService;
import com.orgmemory.core.assetregistry.promptcontract.PromptRunResult;
import com.orgmemory.core.assetregistry.SkillPackageStoragePort;
import com.orgmemory.core.assetregistry.skillstorage.SkillPackageStoragePort;
import com.orgmemory.core.assetregistry.SkillRegistryService;
import com.orgmemory.core.assetregistry.WorkInstructionService;
import com.orgmemory.core.assetregistry.WorkInstructionView;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
package com.orgmemory.worker.assetregistry;

import com.orgmemory.core.assetregistry.SkillPackageCleanupOutcome;
import com.orgmemory.core.assetregistry.SkillPackageSupersessionCleanupService;
import java.util.Map;
import com.orgmemory.core.assetregistry.skillcleanup.SkillPackageCleanupOperations;
import com.orgmemory.core.assetregistry.skillcleanup.SkillPackageCleanupSummary;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
Expand All @@ -14,20 +13,20 @@ class SkillPackageSupersessionCleanupScheduler {
private static final Logger LOGGER =
LoggerFactory.getLogger(SkillPackageSupersessionCleanupScheduler.class);

private final SkillPackageSupersessionCleanupService cleanup;
private final SkillPackageCleanupOperations cleanup;

SkillPackageSupersessionCleanupScheduler(
SkillPackageSupersessionCleanupService cleanup) {
SkillPackageCleanupOperations cleanup) {
this.cleanup = cleanup;
}

@Scheduled(
fixedDelayString =
"${orgmemory.asset-registry.skill-package-cleanup-interval:1m}")
void cleanup() {
Map<SkillPackageCleanupOutcome, Integer> outcomes = cleanup.cleanupPending(25);
if (!outcomes.isEmpty()) {
LOGGER.info("Skill package supersession cleanup outcomes={}", outcomes);
SkillPackageCleanupSummary summary = cleanup.cleanupPending(25);
if (!summary.isEmpty()) {
LOGGER.info("Skill package supersession cleanup summary={}", summary);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.orgmemory.worker;

import static org.junit.jupiter.api.Assertions.assertEquals;

import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
import java.util.Set;
import java.util.TreeSet;
import org.junit.jupiter.api.Test;

class SkillCapabilityBoundaryTests {

@Test
void workerImportsOnlyTheCleanupCapability() {
var dependencies = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages("com.orgmemory.worker")
.stream()
.flatMap(type -> type.getDirectDependenciesFromSelf().stream())
.map(dependency -> dependency.getTargetClass().getName())
.filter(name -> name.startsWith(
"com.orgmemory.core.assetregistry.skill"))
.collect(TreeSet::new, Set::add, Set::addAll);

assertEquals(
Set.of(
"com.orgmemory.core.assetregistry.skillcleanup.SkillPackageCleanupOperations",
"com.orgmemory.core.assetregistry.skillcleanup.SkillPackageCleanupSummary"),
dependencies);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.orgmemory.core.assetregistry;

import com.orgmemory.core.assetregistry.skillstorage.SkillPackageStoragePort;
import com.orgmemory.core.shared.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.orgmemory.core.assetregistry;

import com.orgmemory.core.assetregistry.skillstorage.SkillPackageStoragePort;
import com.orgmemory.core.assetregistry.consumption.AssetAvailability;
import com.orgmemory.core.assetregistry.consumption.AssetConsumptionRelease;
import com.orgmemory.core.assetregistry.consumption.AssetPublicationMode;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.orgmemory.core.assetregistry;

import com.orgmemory.core.assetregistry.skillstorage.SkillPackageStoragePort;
import com.orgmemory.core.assetregistry.consumption.AssetAvailability;
import com.orgmemory.core.assetregistry.consumption.AssetConsumptionRelease;
import com.orgmemory.core.assetregistry.consumption.AssetReleaseUseQuery;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,109 +1,68 @@
package com.orgmemory.core.assetregistry;

import com.orgmemory.core.assetregistry.consumption.AssetConsumptionRelease;

import com.orgmemory.core.assetregistry.api.AssetIdentity;
import com.orgmemory.core.assetregistry.api.AssetIdentityQuery;
import com.orgmemory.core.assetregistry.api.AssetNotFoundException;
import com.orgmemory.core.assetregistry.api.AssetType;
import com.orgmemory.core.assetregistry.api.AssetUnavailableException;
import com.orgmemory.core.assetregistry.consumption.AssetConsumptionRelease;
import com.orgmemory.core.assetregistry.skilldelivery.SkillReleaseContent;
import com.orgmemory.core.assetregistry.skilldelivery.SkillReleaseDeliveryQuery;
import com.orgmemory.core.assetregistry.skilldelivery.SkillReleaseDescriptor;
import com.orgmemory.core.assetregistry.skillpackage.SkillPackageArtifact;
import com.orgmemory.core.organization.CurrentActor;
import java.util.Objects;
import java.util.regex.Pattern;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

/**
* Canonical authenticated Skill distribution boundary.
*/
/** Canonical authenticated Skill distribution boundary. */
@Service
public class SkillDistributionService {

private static final Logger log =
LoggerFactory.getLogger(SkillDistributionService.class);
private static final Pattern COORDINATE =
Pattern.compile("[a-z0-9]+(?:[._-][a-z0-9]+)*");

private final AssetRegistryService assets;
private final AssetIdentityQuery identities;
private final AssetReleaseRepository releaseRepository;
private final AssetPayloadReferenceRepository references;
private final SkillReleaseDeliveryQuery deliveries;
private final SkillPackageSpecReader specs;
private final SkillPackageStoragePort storage;

SkillDistributionService(
AssetRegistryService assets,
AssetIdentityQuery identities,
AssetReleaseRepository releaseRepository,
AssetPayloadReferenceRepository references,
SkillPackageSpecReader specs,
SkillPackageStoragePort storage) {
this.assets = assets;
this.identities = identities;
this.releaseRepository = releaseRepository;
this.references = references;
SkillReleaseDeliveryQuery deliveries,
SkillPackageSpecReader specs) {
this.deliveries = deliveries;
this.specs = specs;
this.storage = storage;
}

public SkillInstallManifest manifest(
CurrentActor actor,
UUID assetId,
UUID releaseId) {
ResolvedSkill resolved = resolve(actor, assetId, releaseId);
CurrentActor actor, UUID assetId, UUID releaseId) {
SkillReleaseDescriptor descriptor =
deliveries.describe(actor, assetId, releaseId);
SkillInstallManifest manifest = manifest(descriptor);
audit(actor, "get_skill_manifest", assetId, releaseId);
return resolved.manifest();
return manifest;
}

public SkillInstallManifest manifest(
CurrentActor actor,
String namespace,
String slug,
String version) {
Objects.requireNonNull(actor, "actor");
AssetIdentity asset = identities
.findByCoordinate(
actor.organizationId(),
normalizeCoordinate(namespace, "namespace"),
normalizeCoordinate(slug, "slug"))
.filter(value -> value.type() == AssetType.SKILL)
.orElseThrow(AssetNotFoundException::new);
String versionLabel;
try {
versionLabel = AssetRelease.validateVersionLabel(version);
} catch (IllegalArgumentException | NullPointerException invalid) {
throw new AssetNotFoundException(invalid);
}
AssetRelease release = releaseRepository
.findByAssetIdAndOrganizationIdAndVersionLabel(
asset.id(),
actor.organizationId(),
versionLabel)
.orElseThrow(AssetNotFoundException::new);
return manifest(actor, asset.id(), release.getId());
SkillReleaseDescriptor descriptor =
deliveries.describe(actor, namespace, slug, version);
SkillInstallManifest manifest = manifest(descriptor);
audit(
actor,
"get_skill_manifest",
descriptor.release().assetId(),
descriptor.release().releaseId());
return manifest;
}

public SkillPackageContent open(
CurrentActor actor,
UUID assetId,
UUID releaseId) {
ResolvedSkill resolved = resolve(actor, assetId, releaseId);
SkillPackageStoragePort.StoredSkillPackageContent content;
try {
content = storage.open(resolved.reference().getReferenceValue());
} catch (RuntimeException unavailable) {
throw new AssetUnavailableException(
"The Skill package is temporarily unavailable",
unavailable);
}
CurrentActor actor, UUID assetId, UUID releaseId) {
SkillReleaseContent content = deliveries.open(actor, assetId, releaseId);
try {
verifyStored(resolved.reference(), content.metadata());
SkillInstallManifest manifest = manifest(content.descriptor());
audit(actor, "download_skill_package", assetId, releaseId);
return new SkillPackageContent(
resolved.manifest(),
fileName(resolved.manifest()),
manifest,
fileName(manifest),
content.content());
} catch (RuntimeException invalid) {
try {
Expand All @@ -115,28 +74,17 @@ public SkillPackageContent open(
}
}

private ResolvedSkill resolve(
CurrentActor actor,
UUID assetId,
UUID releaseId) {
Objects.requireNonNull(actor, "actor");
AssetConsumptionRelease release =
assets.releaseForUse(actor, assetId, releaseId, AssetType.SKILL);
private SkillInstallManifest manifest(SkillReleaseDescriptor descriptor) {
AssetConsumptionRelease release = descriptor.release();
SkillPackageSpec spec;
try {
spec = specs.read(release.payload());
} catch (RuntimeException invalid) {
throw new AssetUnavailableException(
"The Skill release manifest is unavailable",
invalid);
"The Skill release manifest is unavailable", invalid);
}
AssetPayloadReference reference = references
.findByReleaseIdAndOrganizationId(
releaseId, actor.organizationId())
.orElseThrow(() -> new AssetUnavailableException(
"The Skill release package is unavailable"));
verifyReference(spec, reference);
SkillInstallManifest manifest = new SkillInstallManifest(
verifyReference(spec, descriptor.artifact());
return new SkillInstallManifest(
release.assetId(),
release.releaseId(),
release.namespace(),
Expand All @@ -156,58 +104,26 @@ private ResolvedSkill resolve(
spec.metadata(),
spec.files().stream()
.map(file -> new SkillInstallManifest.File(
file.path(),
file.size(),
file.sha256()))
file.path(), file.size(), file.sha256()))
.toList());
return new ResolvedSkill(manifest, reference);
}

private static void verifyReference(
SkillPackageSpec spec,
AssetPayloadReference reference) {
if (!reference.isBlobReference()
|| !spec.artifact().sha256().equals(reference.getDigest())
|| spec.artifact().contentLength()
!= reference.getContentLength()
|| !spec.artifact().mediaType()
.equals(reference.getMediaType())) {
SkillPackageSpec spec, SkillPackageArtifact artifact) {
if (!spec.artifact().sha256().equals(artifact.sha256())
|| spec.artifact().contentLength() != artifact.contentLength()
|| !spec.artifact().mediaType().equals(artifact.mediaType())) {
throw new AssetUnavailableException(
"The Skill release package metadata is inconsistent");
}
}

private static void verifyStored(
AssetPayloadReference reference,
SkillPackageStoragePort.StoredSkillPackage stored) {
if (!reference.getReferenceValue().equals(stored.objectKey())
|| !reference.getDigest().equals(stored.sha256())
|| reference.getContentLength() != stored.contentLength()
|| !reference.getMediaType().equals(stored.mediaType())) {
throw new AssetUnavailableException(
"The stored Skill package failed its integrity check");
}
}

private static String fileName(SkillInstallManifest manifest) {
String version = manifest.version()
.replaceAll("[^A-Za-z0-9._-]", "-");
return manifest.slug() + "-" + version + ".zip";
}

private static String normalizeCoordinate(
String value, String field) {
String normalized = Objects.requireNonNull(value, field)
.strip()
.toLowerCase(java.util.Locale.ROOT);
if (normalized.isEmpty()
|| normalized.length() > 128
|| !COORDINATE.matcher(normalized).matches()) {
throw new AssetNotFoundException();
}
return normalized;
}

private static void audit(
CurrentActor actor,
String action,
Expand All @@ -221,9 +137,4 @@ private static void audit(
assetId,
releaseId);
}

private record ResolvedSkill(
SkillInstallManifest manifest,
AssetPayloadReference reference) {
}
}
Loading