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
13 changes: 13 additions & 0 deletions GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Shared terms match the skilld CLI glossary (`skilld-dev/skilld/GLOSSARY.md`). Th
| Agent | the user's tool | Agent 1β€”N Agent target | "your agent" |
| Repository | GitHub | Repository 1β€”N Skill | "repository" |
| Artifact | skilld.dev API | Skill commit 1β€”1 Artifact | not shown |
| linked file | Artifact attestation | Artifact 1β€”N linked file | not shown |
| source status | lockfile, skilld.dev API | Artifact 1β€”1 source status | "Verified", "Unverified", "Local" |
| run | skilld CLI | Skill 1β€”1 run command | "run" |
| demo | Skill page, homepage | Skill 1β€”N demo | "demo" |
Expand Down Expand Up @@ -262,6 +263,18 @@ Existing route paths, table names, and protocol fields keep their identifiers.

**Never:** signature (bare), certificate, receipt.

### linked file

**Is:** a Skill file the Artifact attestation lists by path, size, and Git blob SHA, without packing its bytes. The skilld CLI reads it from GitHub at the attested commit and checks it against that SHA. Only a CLI that sends `Skilld-Capabilities: linked-files` gets one. See ADR-0013.

**Never:** external file, remote asset, download, attachment.

### omitted file

**Is:** a Skill file left out of an Artifact for a size limit. The `omitted-files` check result names each one. A CLI that reads linked files gets a large file as a linked file instead.

**Never:** skipped file, excluded file, dropped file.

### check result

**Is:** one named check, its version, its finding, and its outcome for one Artifact.
Expand Down
114 changes: 114 additions & 0 deletions docs/adr/0013-artifact-size-limits-and-linked-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# ADR-0013: Artifact size limits and linked files

Date: 2026-10-07

Amends [ADR-0005](0005-v3-cli-harness-artifact-delivery.md).

## Context

A public build read every Skill file into memory, then packed a second copy as the archive.
Its limits were 10 MiB an Artifact, 2 MiB a file, and 900 files.
Only the 900 had a recorded reason: one blob read per file, against 1,000 subrequests.

#487 kept Skills over those limits running by leaving files out.
`latent-spaces/brag` then ran without the four music files its films use.
`oaustegard/claude-skills/tree-sitting` ran without eight of its parsers.

Each limit now names the constraint that sets it, measured on 2026-10-07.

## Constraints

| Constraint | Real limit | Old limit | New limit |
|---|---|---|---|
| Worker memory | 128 MB per isolate, shared with site requests | 10 MiB of files plus their 10 MiB archive, held at once | None from size. A build holds one network chunk, one inflate write of at most 16 KiB, SKILL.md, and the archive when it is at most 16 MiB |
| Worker CPU | 300 s for the queue consumer | Not measured | 1 GiB of archive per pass. Inflating and walking costs 4 to 5 ms per MiB, so about 5 s |
| Wall time | 15 min for the consumer. The skilld CLI waits 60 s for one Resolution | Not measured | A pass stops after the last Skill file. Cold codeload served PostHog/posthog (191 MiB gzipped) in 26 s |
| Subrequests | 1,000 per invocation | 900 files, one blob read each | No file limit. One or two archive reads, and at most 400 single-file reads for files the archive lacks |
| R2 put | 5 GiB in one part | Bytes in memory | A stream of known length in one part. R2 checks it against the SHA-256 the scan found |
| D1 row | 2,000,000 bytes | Not measured | A file list of 448 KiB. The Resolution row holds about 3.34 statements plus the check results |
| Signer | 128 MB per isolate | 10 MiB, read whole | 64 MiB, hashed as it streams |
| skilld CLI 3.0 to 3.6.2 | 64 MiB download, 2,000 files, 6 MiB statement. Before 3.6.2 a download gets 30 s, so 64 MiB needs 2.2 MB/s | 10 MiB, 900 files | 64 MiB and 2,000 files. These two now bind |
| USTAR | A path of at most 256 bytes | Checked after packing | Refused before any byte is read |
| [GitHub](https://github.com) | 100 MiB a file | Not applicable | Linked files of at most 256 MiB in all |

A private build keeps 10 MiB, 2 MiB a file, and 900 files.
It still reads one blob per request and encrypts the archive in memory.

## Decision

### A build streams the Repository archive

A public build reads `https://codeload.github.com/{owner}/{repository}/tar.gz/{commit}`.
That is one subrequest and spends no REST quota.
It inflates the archive in 16 KiB writes and keeps only the Skill folder's entries.
It stops reading after the last Skill file.

The Git tree still names every file, its mode, its size, and its blob SHA.
Each file's bytes are hashed as they pass and must match that blob SHA.
The archive is only a byte source, as before.

A file the archive lacks, or holds with another size, is read from `raw.githubusercontent.com` at the same commit.
Git LFS pointers, `export-ignore`, and `export-subst` produce those cases.
A file whose archive bytes keep their size and fail the digest is read the same way on a second pass.

Artifact order is the UTF-8 byte order of paths.
It is the order a Git archive lists a tree in.
All 487 archives of the 2026-10-07 sweep listed their files in that order.

The first pass learns the archive digest and size, the file inventory, and the check results.
An archive of at most 16 MiB stays in memory, and R2 stores it from there.
A larger one is read from GitHub a second time and streams into R2 under the digest from the first pass.
R2 refuses the put if one byte differs.
The store happens before the checks are recorded.
The CLI gives each stage after the checks 15 seconds.

### Limits

A public Artifact holds at most 64 MiB and 2,000 files.
A file has no limit of its own.

When the files exceed a limit, the largest leave the archive first.
For a CLI without linked files, only files the Skill does not read leave, and the `omitted-files` check lists them.
That is the #487 rule at the new limits.
More files than 2,000 leave the same way, so linked files do not change the file count.

### Linked files

A skilld CLI that sends `Skilld-Capabilities: linked-files` on `POST /api/v1/resolutions` may get linked files.
The Resolution stores that as `linked_files`, and the request fingerprint names it.

When the archive would pass 64 MiB, the largest files other than SKILL.md become linked files, up to 256 MiB.
The signed statement lists each one:

```json
{
"linkedFiles": [
{ "path": "scripts/KimiXlsx", "mode": 420, "size": 77001601, "gitBlobSha": "<40 hex characters>" }
]
}
```

The CLI verifies the attestation first.
It then reads each linked file from `https://raw.githubusercontent.com/{owner}/{repository}/{commitSha}/{skillPath}/{path}`, each path segment percent-encoded.
It refuses the Skill unless every file has the declared size and Git blob SHA.
It installs the file with the declared mode, beside the archive's files.
The bytes never pass through skilld.dev, and the signature still covers them through the blob SHA.

The field is present only when it is not empty.
A CLI without linked files never receives it, because it refuses a statement field it does not know.
A build with linked files serves only a CLI that reads them.
A build that left nothing out serves both kinds.

## Consequences

`latent-spaces/brag` runs with its music: 289 files, 16.03 MiB, nothing left out.
`tt-a1i/archify` runs whole at 10.36 MiB.
`thedivergentai/gd-agentic-skills/godot-master` runs with 1,728 files.
`thvroyal/kimi-skills/kimi-xlsx` runs its 73 MiB binary as a linked file for a CLI that reads one.

A first run of a Skill late in a large archive can take longer than the CLI's 60-second wait.
The build still finishes, and the next run reuses it.

The previous policy `2026-10-07.2` stays signable. This change closes no safety gap.
A ready build under that policy that left nothing out packs the same files, so it is checked again from its bytes.
A stored archive over 16 MiB is never read back whole. It loads from GitHub.
3 changes: 3 additions & 0 deletions docs/arch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ One content hash can have several attestations with different sources or signing
The grant must return the requested Resolution's attestation, or deny delivery.
Public grants without the header select the newest attestation. Private grants retain Account access checks.

A public build streams the Repository archive from codeload and never holds the whole Skill.
The limits, the constraint behind each, and linked files: [ADR-0013](../adr/0013-artifact-size-limits-and-linked-files.md).

After a contract change in the `skilld-dev/skilld` repository, run `pnpm --filter skilld-sdk generate` and commit the document.
Publish the new SDK version from that repository's `release.yml`. Then update this site's exact catalog pin.
The route parity test checks the site against the published contract.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ import { failResolution, processArtifactBuild } from '../../../utils/build'
import { findPrivateRepositoryAccess } from '../../../utils/private-access'
import { privateArtifactAccessEnabled } from '../../../utils/private-feature'
import { createArtifactBuildDependencies, enqueueArtifactBuild } from '../../../utils/queue'
import { buildAfterResponse, fetchAdmittedSkillIdentity, requestResolution } from '../../../utils/request-resolution'
import {
buildAfterResponse,
CLIENT_CAPABILITIES_HEADER,
fetchAdmittedSkillIdentity,
readsLinkedFiles,
requestResolution,
} from '../../../utils/request-resolution'
import { setSkillPageUrlHeader } from '../../../utils/skill-page'
import { getResolution } from '../../../utils/state'

Expand Down Expand Up @@ -65,6 +71,7 @@ export default withArtifactProblems(defineApiHandler({
}, {
source: body.source,
idempotencyKey,
linkedFiles: readsLinkedFiles(getHeader(event, CLIENT_CAPABILITIES_HEADER)),
access: privateAccess._tag === 'allowed'
? {
visibility: 'private',
Expand Down
15 changes: 15 additions & 0 deletions layers/artifact-delivery/server/schemas/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ export const artifactFileSchema = z.object({
sha256: z.string().regex(SHA256_PATTERN),
}).strict()

/**
* A Skill file the Artifact does not pack. The skilld CLI reads it from
* GitHub at the attested commit and checks it against its Git blob digest.
* Only a CLI that sends `Skilld-Capabilities: linked-files` gets one.
*/
export const linkedArtifactFileSchema = z.object({
path: z.string().min(1).max(1024),
mode: z.union([z.literal(420), z.literal(493)]),
size: z.number().int().nonnegative(),
gitBlobSha: z.string().regex(COMMIT_SHA_PATTERN),
}).strict()

export const attestationSignatureSchema = z.object({
algorithm: z.literal('Ed25519'),
keyId: z.string().min(1).max(100),
Expand All @@ -78,6 +90,8 @@ export const artifactAttestationStatementSchema = z.object({
policyVersion: z.string().min(1).max(100),
files: z.array(artifactFileSchema).min(1).max(2000),
checkResults: z.array(checkResultSchema).min(1).max(100),
/** Absent unless a file is linked: a CLI without linked files refuses the field. */
linkedFiles: z.array(linkedArtifactFileSchema).min(1).max(2000).optional(),
}).strict()

export const artifactAttestationSchema = artifactAttestationStatementSchema.extend({
Expand Down Expand Up @@ -216,6 +230,7 @@ export type SourceRequest = z.infer<typeof sourceRequestSchema>
export type ResolvedSource = z.infer<typeof resolvedSourceSchema>
export type CheckResult = z.infer<typeof checkResultSchema>
export type ArtifactFile = z.infer<typeof artifactFileSchema>
export type LinkedArtifactFile = z.infer<typeof linkedArtifactFileSchema>
export type ArtifactAttestationStatement = z.infer<typeof artifactAttestationStatementSchema>
export type ArtifactAttestation = z.infer<typeof artifactAttestationSchema>
export type ResolutionResponse = z.infer<typeof resolutionSchema>
Expand Down
Loading
Loading