Task — engineering-spec@1

"SKILL OCI registry deploy for `.skill` bundles — R3 distribution stage with signed bundles + tag immutability + tenant-scoped pulls"

doneTASK-SKILL-201
module skill · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-SKILL-102 · blocks none

§1 — Description (BCP-14 normative)

The SKILL service MUST ship OCI registry deploy at services/skill/src/oci/ with push/pull + cosign signing + tag immutability + tenant ACL, 5 memory audit kinds.

  1. MUST validate bundle_status against closed enum per DEC-2421.
  1. MUST push at push.rs::push(bundle_path, registry, tag, tenant_acl) per DEC-2420:
  1. MUST enforce tag immutability per DEC-2423 — pre-push: check registry for existing tag; reject if exists.
  1. MUST verify cosign signature on pull at pull.rs::pull(registry, tag) per DEC-2422 — cosign_wrapper.rs::verify(bundle, signature) against registered key; reject + sev-1 audit on mismatch.
  1. MUST check tenant ACL per DEC-2424 at acl_check.rs::can_pull(bundle, requester_tenant) — match against bundle.tenant_acl JSONB.
  1. MUST support yank per DEC-2423 — sets status=yanked; pull returns warning but bundle still retrievable for audit.
  1. MUST define table at migration 0010: ``sql CREATE TABLE skill_oci_bundles ( bundle_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, registry TEXT NOT NULL, image_name TEXT NOT NULL, tag TEXT NOT NULL, digest TEXT NOT NULL, cosign_signature_bytes BYTEA, tenant_acl JSONB NOT NULL, -- {"allowed_tenants": ["uuid1", "uuid2"], "public": false} status TEXT NOT NULL DEFAULT 'pushed' CHECK (status IN ('pushed','signed','validated','available','deprecated','yanked')), pushed_by UUID NOT NULL, yanked_at TIMESTAMPTZ, yanked_reason TEXT, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (registry, image_name, tag) ); ALTER TABLE skill_oci_bundles ENABLE ROW LEVEL SECURITY; CREATE POLICY bundles_rls ON skill_oci_bundles USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON skill_oci_bundles FROM cyberos_app; GRANT UPDATE (status, yanked_at, yanked_reason) ON skill_oci_bundles TO cyberos_app; ``
  1. MUST expose endpoints: ``text POST /v1/skill/oci/push body: {bundle_path, registry, tag, tenant_acl} POST /v1/skill/oci/pull body: {registry, image_name, tag} POST /v1/skill/oci/yank/{id} body: {reason} GET /v1/skill/oci/bundles (list with ACL filter) ``
  1. MUST emit 5 memory audit kinds per DEC-2425. PII per TASK-MEMORY-111: digest + signature_bytes SHA256.
  1. MUST thread trace_id from push/pull → audit.
  1. MUST NOT overwrite existing tag per DEC-2423 (UNIQUE constraint enforces).
  1. MUST NOT pull without signature verify per DEC-2422.
  1. MUST NOT allow cross-ACL pull per DEC-2424.

§2 — Why this design

Why OCI (DEC-2420)? Industry standard; GHCR/ECR/etc. interoperable; cosign signing supports it natively.

Why cosign (DEC-2422)? Supply chain integrity; CNCF-graduated; widely audited.

Why tag immutability (DEC-2423)? Reproducibility — same tag must always pull same bytes; otherwise downstream caching breaks.

Why ACL (DEC-2424)? Multi-tenant SaaS — each tenant's custom skills shouldn't leak across.


§3 — API contract

Sample push:

POST /v1/skill/oci/push
{
  "bundle_path": "./my-skill.skill",
  "registry": "ghcr.io/cyberskill",
  "tag": "calendar-list@1.2.0",
  "tenant_acl": {"allowed_tenants": ["uuid-1"], "public": false}
}

§4 — Acceptance criteria

  1. bundle_status enum cardinality 6. 2. Push to OCI works. 3. Cosign signature applied. 4. Tag overwrite rejected (UNIQUE). 5. Pull verifies signature. 6. Bad signature → sev-1 + reject. 7. ACL enforced on pull. 8. Yank sets status, doesn't delete. 9. 5 memory audit kinds emitted. 10. PII scrubbed (digest+sig SHA256). 11. RLS denies cross-tenant. 12. CTO-only push. 13. Trace_id preserved. 14. Append-only via REVOKE except status cols. 15. Cosign key from TASK-AUTH-105. 16. public:true bundles pullable by anyone. 17. public:false requires ACL match. 18. List endpoint filters by ACL. 19. OCI spec compliance. 20. Bundle validation pre-push.

§5 — Verification

#[tokio::test]
async fn tag_immutability() {
    let ctx = TestContext::with_pushed_bundle().await;
    let r = ctx.try_push_same_tag().await;
    assert!(r.is_err());
}

#[tokio::test]
async fn signature_verify_on_pull() {
    let ctx = TestContext::with_signed_bundle().await;
    let r = ctx.pull(ctx.registry, ctx.tag).await;
    assert!(r.is_ok());
    ctx.tamper_bundle(ctx.bundle_id).await;
    let r2 = ctx.try_pull(ctx.registry, ctx.tag).await;
    assert!(r2.is_err());
}

#[tokio::test]
async fn acl_blocks_cross_tenant() {
    let ctx = TestContext::with_private_bundle_for_tenant_a().await;
    let r = ctx.try_pull_as_tenant_b(ctx.bundle_id).await;
    assert_eq!(r.status_code, 403);
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-SKILL-102. Cross-module: TASK-AUTH-105 (KMS for cosign), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Registry unreachableretrysev-2retry
Tag overwriteUNIQUE409new tag
Signature mismatchverifyreject + sev-1investigate
ACL missgate403get permission
Cosign key expiredKMSsev-1rotate
Bundle malformedpre-push validate400fix bundle
Network partition pullretrysev-2inherent
Yank of in-use bundlewarn but allowinherentinherent
Cross-tenant pushRLSinherentinherent
Quota exceeded on registrysev-2inherentupgrade

§11 — Implementation notes


End of TASK-SKILL-201 spec.