Skip to content

feat(contracts): signed skillx.release/v1 manifest, release status and migration 0011 - #58

Merged
mrgoonie merged 15 commits into
mainfrom
feat/skill-release-contract
Oct 13, 2026
Merged

mrgoonie merged 15 commits into
mainfrom
feat/skill-release-contract

Conversation

@mrgoonie

@mrgoonie mrgoonie commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This is slice SX-2 of #54: the signed skillx.release/v1 contract, a separate signed skillx.release-status/v1 document, and an additive D1 migration 0011. It is stacked on #57 (skill tree digest) and should be reviewed after it.

  • skillx.release/v1 manifest: immutable, one per release. It is signed with Ed25519 over canonical JSON (the payload excludes signature) and carries treeDigest, identity, publisher (verifiedBy: none|github|admin), limits and sourceProvenance (nullable; consumers must label a null value "provenance unknown"). It has no state, so it never changes after signing.
  • skillx.release-status/v1 status:
    • Lifecycle state (yank, quarantine) is signed separately and bound to exactly one release through manifestDigest, the sha256 of the manifest signing payload.
    • A status whose sequence is lower than the last accepted one is rejected (status.rollback).
    • Two different statuses with the same sequence are rejected (status.conflict).
    • A status for another release is rejected (status.manifest_mismatch).
    • An unknown state fails closed.
  • Cross-language vectors in packages/contracts/src/fixtures/. A Go verifier (AgentKit side) reproduced every valid and invalid result.
  • Migration 0011: additive only. It adds the tree digest columns and index, plus status_sequence and status_issued_at, with rollback SQL in the header. A test applies 0000..0011 on SQLite and checks that a database already at 0010 keeps its rows.
  • @skillx/skill-tree/rules: a new entry point with no Node imports, so the web typecheck can use the identity rules.

Not in this PR (owner only)

  • Applying 0011 to remote D1, after a backup bookmark.
  • Generating the registry Ed25519 signing key, storing it as a Cloudflare secret and publishing its key ID.

Notes

  • Migration 0011 was hand-trimmed to its additive part. drizzle-kit generate wanted to re-create seven tables because the 0010 snapshot is missing them; the generated 0011 snapshot is kept, and the header explains this.
  • SX-3 must store and serve the full signed status document; there is no reason column.
  • Known and unrelated:
    • packages/cli/src/version.test.ts needs a bare pnpm on PATH.
    • The unused apps/web/tests/tsconfig.json already reports type errors.

Validation (offline)

  • packages/contracts: 203 passed, 2 skipped (the vector writers).
  • packages/skill-tree: 583 passed.
  • apps/web: 99 passed.
  • tsc and the web typecheck are clean, and the drizzle drift check is clean.

Refs #54, bestagentkits/agentkit#2277

Review rounds

  • Round 1 fixes (9527f9c, f33c91c, 35d2ba8): releaseDigest bound to the tree digest, required expiresAt (7 day cap, status.expired checked after the signature), exact canonical bytes, signed state transition graph, re-appliable 0011 rollback, strict release semver, bounded reason.
  • Round 2 fixes (5a95cf9): status.not_yet_valid for issuedAt more than 300 s ahead, pinned Unicode 15.0 classes for reason, base64 padding-bit vectors, docs for the 24 h re-sign cadence, issuedAt monotonicity and the two-step quarantine lift.
  • Validation after round 2: skill-tree 585, contracts 294 (2 skipped), web 99 pass; typechecks clean; Go cross-check over all vectors (manifest 3 valid / 16 rejected, status 9 valid / 36 invalid) has 0 failures.

Follow-up

  • trustOf in skill-tree identity.ts still returns publisher-signed for a verified skillx identity because it mirrors the vendored AgentKit identity vectors. It changes together with those vectors when bestagentkits/agentkit#2277 adds registry-signed.
  • The local dev D1 holds the older 0011 text; rebuild it before relying on it locally. Remote D1 is untouched.

…and identity rules

Add @skillx/skill-tree, a TypeScript implementation of the AgentKit
skill tree contract shared with the AgentKit CLI:

- canonical serialization and sha256 tree digest
- path, NFC and case-fold collision rules on Unicode 15.0.0 tables
  generated from pinned inputs, never runtime Intl data
- streaming tar/tar.gz and zip readers with exact entry, file and byte
  limits enforced while content streams (pull-driven inflate)
- package identity, alias and directory alias rules, provenance trust
  and frontmatter exposure

The AgentKit conformance vectors are vendored unedited with a provenance
note and sha256 drift tests; every tree and identity vector passes.
Negative tests cover links, special entries, sparse files, unsafe modes
and names, case collisions, damaged archives and decompression bombs.

Refs #54
…uests

The repository has no pull request CI yet, so run the package typecheck
and its conformance suite when the package, the lockfile or the test
config change.

Refs #54
- NFC/NFD follow x/text stream-safe processing: a run of more than 30
  non-starters gets a CGJ, so such a path is non-NFC; composition ports
  reorderBuffer.compose; counts and differential vectors come from Go
- header modes outside a safe, non-negative 7-digit octal value are
  rejected before the type bits are cleared
- zip entries with header faults or non-regular types are unsupported
  without inflating their data or counting toward file and byte limits
- tar reads stay within a total byte budget covering non-regular data
  and PAX/GNU extension chains
- GNU sparse files apply GNU.sparse.name, size and realsize and validate
  their maps as Go does; malformed maps fail with tree.read_failed
- numeric and PAX parsing match Go: NUL-cut octal, negative base-256,
  signed and zero-padded PAX lengths, PAX times, all header numerics
- an empty GNU long name clears an earlier one; PAX records live in a
  prototype-free object; non-safe sizes fail the limit step
- collision keys fold each path once; vector harness asserts reader
  coverage
… guard

Rewrite type NUL from the merged name before sparse handling, as Go
Reader.Next does, and use that type for the data length, the sparse
header-only check and the entry kind. Check the raw header size for
negativity before a PAX size overrides it.

The tar byte budget now counts only bytes that are not hashed file
content, at maxFiles x 64 KiB, and its detail names the ingest
overhead guard. Document the guard and the nodejs_compat requirement.
Pin that a type NUL entry whose trailing slash comes from a PAX path or a
GNU long name reads no data, and keep the vector helper from writing data
for a type NUL directory.
Export the path, collision and identity rules from @skillx/skill-tree/rules
so Worker code can validate manifests without pulling node:crypto or the
archive readers into its program.
Define the release manifest that pins a package version to its
agentkit.skill-tree/v1 tree digest, a strict validator, byte-exact
canonical JSON for the signing payload, and WebCrypto Ed25519 signing and
fail-closed verification compatible with the AgentKit registry style.

Ship cross-language vectors signed with a key derived from a public seed
text, plus a skill-tree test that recomputes each vector's tree digest.
Add release_digest, file_count, tree_size, signed_manifest_key and
quarantined_at to package_releases, packages.visibility and
publishers.state with safe defaults, and a release digest index.

The generator re-emitted the registry tables missing from the 0010
snapshot; the SQL keeps only the additive delta and records rollback SQL.
Keep the skillx.release/v1 manifest immutable per release digest and
move the mutable state into skillx.release-status/v1: release digest,
state, optional reason, issuedAt, a strictly increasing sequence and the
signing key, under the same canonical bytes and Ed25519 rules.

Verification fails closed on an unknown state, a status for another
release, a bad signature, a lower sequence (rollback) and a different
status with the accepted sequence (conflict). Ship cross-language status
vectors, regenerate the manifest vectors without state, and record the
last status sequence and issue time in migration 0011.

Document that a release without source provenance must be labeled
provenance unknown.
The same tree can be released under several identities or versions, so
a status keyed only by releaseDigest would apply to every sibling
release. Add a required manifestDigest: the sha256 of the manifest
signing payload, the exact bytes the manifest signature covers.

Verification now rejects a status for another manifest with
status.manifest_mismatch, and rollback and conflict checks track the
sequence per manifest digest. Vectors list the signed manifests with
their digests and cover one tree under two versions and a missing or
wrong manifest digest. Document that the registry stores the full
signed status document, so reason needs no database column.
Move the agentkit.skill-tree/v1 serialization (header, per-file lines,
UTF-8 path ordering) into a module without Node.js imports, and export
serializeTree, compareUtf8 and sortFiles from @skillx/skill-tree/rules.
Worker code can now render the exact digest bytes and hash them with
WebCrypto. The root entry keeps treeDigest over node:crypto.
Manifest:
- sign and verify require releaseDigest to be the tree digest of files
  (release.tree_digest_mismatch), hashed with WebCrypto
- verifiers take the received bytes and require them to equal the
  canonical JSON (release.non_canonical), rejecting duplicate members,
  number and escape variants, whitespace, BOM and invalid UTF-8
- release versions reject build metadata and leading zeros (max 128)
- signatures must use the single canonical base64 text
- validators reject non-plain objects and sparse arrays without throwing

Status:
- required expiresAt, at most 7 days after issuedAt; verify against a
  caller-supplied now (status.expired) and the verified manifest
- issuedAt not before the manifest createdAt, later than the previous
  accepted status, and transitions enforced (status.invalid_transition)
- reason bounded to 512 UTF-8 bytes without control, format, bidi or
  separator characters; non-canonical bytes rejected

Signed-release fixtures leave the package root export. Vectors become
received documents with one expected code each, add expiry, lifetime,
transition, non-canonical and UTF-8 path ordering cases, and the docs
describe the byte rules, expiry policy and registry-signed trust mapping.
Record the d1_migrations row removal in the 0011 rollback SQL so a later
apply runs the migration again, and select migrations by name in its
test so a future migration does not shift the 0010 baseline.
…lasses

Reject a signed release status whose issuedAt is more than 300 seconds
ahead of the consumer clock with status.not_yet_valid, checked after
the signature like expiry, with vectors at and beyond the skew edge.

Classify reason format characters with the pinned Unicode 15.0 tables
that the tree path rules use, so every runtime agrees on the verdict.

Add manifest and status vectors whose signature has non-zero unused
base64 bits, rejected by the signature pattern although lenient
decoders return the original bytes.

Document that consumers must not map these releases to publisher
trust, the 24 hour re-sign cadence under the 7 day cap, the issuer
obligation to advance issuedAt, and the two-step quarantine lift.
@mrgoonie
mrgoonie marked this pull request as ready for review October 13, 2026 05:42
@mrgoonie
mrgoonie changed the base branch from feat/skill-tree-digest to main October 13, 2026 07:52
…ntract

# Conflicts:
#	packages/skill-tree/README.md
#	packages/skill-tree/package.json
#	packages/skill-tree/src/serialize.ts
@mrgoonie
mrgoonie merged commit beafa56 into main Oct 13, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant