The shared /packs spec

One manifest. Many hosts.
Refusal instant and nameable.

packstruct defines how a /packs directory works - manifest, entry contract, lifecycle, refusals - so an author who writes one pack knows how packs work everywhere. Currently hosted by crashtest (scenario profile) and poststruct (check profile).

# the directory contract
packs/
  public/
    <pack-name>/
      pack.json      # REQUIRED: schemaVersion 1, name matches dir, entry exists
      <entry>        # REQUIRED: created by pack.json "entry"

ok tg/data-boundary       v1.0.0 - entry ships, name matches its dir
ERR tg/broken-ref          entry "index.js" does not exist - instant nameable refusal (crashtest#16)

What froze at v1

The whole shared surface - tight by design. Full normative text on the spec page.

section The contract

packs/public/<name>/ carries a frozen pack.json (schemaVersion 1, name matches directory, entry must exist) plus its entry module. A manifest declaring a file that doesn't ship is an instant, nameable refusal - never a deep import stack trace.

section The linter

npx packstruct lint packs/ - zero dependencies, kills the missing-entry class at author time, in any fleet repo. Machine-readable with --json.

section Profiles

The manifest surface never forks; execution semantics are declared as profiles: scenario (crashtest: hostile scenarios against a transport, isolated) and check (poststruct: pure-reading verifiers over files on disk).

section Lifecycle

Packs are explicit - a pack runs only because the host's config enumerates it. Pack ids are namespaced by pack slug; every pack ships self-audit fixtures; extra manifest keys are minimum-spec and left alone.

The pipeline

How a pack goes from directory to loaded, everywhere.

01Author the packa directory under packs/public/, one pack.json, one entry module - any host, same three fields minimum.
02Lint itnpx packstruct lint packs/ catches the manifest-points-at-nothing class before any host ever loads it.
03Host enumeratesno implicit discovery: the pack runs because the host's config names it. Cross-namespace id collision = refuse to start.
04Host owns the profilethe manifest says nothing about execution; the host's declared profile (scenario or check) owns sandboxing and arming.
05Spec evolves additivelyv2 may add optional fields; removing or renaming is a major bump with a migration note. Engines change first, the spec formalizes after.

Why one spec

The contract already forked in practice - packstruct names the shared part so drift is declared, not accidental.

One address for authors

"How do I write a pack?" has one answer: read packstruct, pick your app's declared profile. No archaeology in two repos.

Frozen from reality

v1 is what crashtest's eight production packs already ship, verified pack-by-pack: nothing to migrate, poststruct adopts fresh.

Cheap future consumers

Any struct-family tool that wants a /packs dir adopts the spec instead of inventing a third dialect.

Conformance, not vibes

The lint CLI is the first artifact; a conformance harness (packstruct audit-conformance) follows at the 1.0.0 ladder rung.