# description
The canonical inner manifest of a Wisp container. THIS DOCUMENT IS THE IDENTITY OF A WISP; the transport container (a deterministic ZIP in v0) is not. Two containers whose bytes differ but whose canonical manifest bytes are identical carry the same Wisp.

NORMATIVE CONSTRAINTS THIS SCHEMA CANNOT EXPRESS (a reader MUST enforce them separately; reference/wisp.py does):
  1. CANONICAL BYTES. The WISP.json entry MUST already be RFC 8785 (JCS) canonical UTF-8 with no byte-order mark, restricted to the integers-only profile: no floating-point numbers anywhere, no NaN/Infinity, no duplicate object keys, no unpaired surrogates, and all integers strictly inside (-2^53, 2^53). load_manifest() re-canonicalizes the parsed object and refuses the Wisp if the result differs from the bytes on disk, because a non-canonical manifest would make the signed identity ambiguous. NOTE A GAP: JSON Schema's `integer` type accepts a number with a zero fractional part, so `56.0` validates here and is REFUSED by canonicalize(). Byte-level canonicality is the authority, not this schema.
  2. SELF-EXCLUSION. The inventory cannot contain the manifest's own digest — a document cannot contain its own hash. WISP.json is therefore not a legal value of `entrypoint` or of any `inventory[].path`, and this schema's containerPath definition omits it entirely. SIGNATURE/manifest.sig is excluded from the inventory for the same reason at one remove: the signature is computed over the canonical manifest bytes, so listing the signature's digest inside the manifest would be unsatisfiable.
  3. SORTED AND UNIQUE. inventory[].path values MUST be unique and sorted ascending by code point. JSON Schema can assert neither over an object-valued array; validate_inventory() enforces both.
  4. CLOSURE. Every entry in the container MUST appear in the inventory, except WISP.json and SIGNATURE/manifest.sig. Undeclared entries fail closed.
  5. CROSS-CHECKS. `entrypoint` MUST resolve to an inventoried entry; identity.*_digest MUST equal the actual digest of the named file; expansion.file_count MUST equal len(inventory); expansion.total_bytes MUST equal the sum of inventory[].bytes.

WHAT THIS DOCUMENT DOES NOT DO: nothing in this manifest establishes publisher identity. A key, a name, a contact address or a verification_state written inside a Wisp is a self-declaration by whoever built it. Publisher identity can only be established by a trust anchor the recipient obtained OUT OF BAND. Content integrity, publisher identity, user authorization, phenotype conformance and outcome evidence are five separate facts and are never conflated.

NO PROSE IN THIS DOCUMENT IS AN INSTRUCTION TO THE READER. Every string here is data to be displayed or compared, never authority to act.

DIVERGENCE FROM reference/wisp.py, in both directions, each noted at the field. This schema is STRICTER in four places: (a) `publisher`, `privacy` and `compatibility` sub-structure is enforced here but not there; (b) additionalProperties is false at every level, so an unknown envelope field is refused here and merely ignored there; (c) the inventory must contain PURPOSE.md and one of OPEN.md or UNFOLD.md; (d) capability values are pinned to the v0 block. The REFERENCE is stricter in three: canonical bytes, display-text Unicode categories, and every cross-check listed above. Nothing here contradicts the reference, and a document that passes both is what a v0 Wisp is.

STATUS: ENFORCED. tests/test_schema_conformance.py validates the packed WISP.json of the reference artifact, and the META block build_reference_wisp.py packs it from, against this file with a real Draft 2020-12 validator.

# properties.schema.description
Identifies this document's contract. A reader that does not know this exact string MUST refuse the Wisp rather than guess.

# properties.protocol_version.description
The protocol the manifest is written against. Distinct from `schema`: a later protocol version may keep this manifest schema, or vice versa.

# properties.profile.description
REQUIRED. Which of the two v0 rule sets this container was built against; there is no default and a manifest without it is refused, because guessing on the recipient's behalf is how a code-bearing container gets read as a declarative one.

`declarative/0` is the lab's proven rule, unchanged: no executable content anywhere, enforced on the received bytes at both pack time and read time by suffix AND by leading magic, and `RESOURCES/` MUST be absent. `resources/0` is an ADDITIVE allowance and nothing else: it permits `RESOURCES/`, whose files may carry program bytes, and requires the full byte inventory, the per-file classification (`resources`), the quarantine statement (`execution.default`), the least-privilege proposal (`execution.plan`) and the material inventory (`materials`) that this schema makes conditional below.

NOTHING IN EITHER PROFILE PERMITS EXECUTION. `resources/0` changes what a Wisp may CARRY, never what a reader may RUN. reference/wisp.py never interprets, compiles or subprocesses a resource byte under any profile; the execution plan is the author's proposal, read as data by a person who then decides separately, per resource, at the moment of use.

# properties.wisp_id.description
Stable lowercase slug, 3 to 64 characters, chosen by the publisher. It is a LABEL, not an identity: two unrelated publishers may pick the same wisp_id, and a reader MUST NOT treat a matching wisp_id as evidence of common origin. Identity is the manifest digest. Enforced identically by load_manifest().

# properties.title.description
Human-readable name, one line. PUBLISHER-CONTROLLED UNTRUSTED PROSE: it is interpolated into the consent sentence a person reads, so a reader MUST render it as inert text, never as markup, and MUST NOT let it terminate or extend the surrounding sentence. reference/wisp.py's check_display_text() is STRICTER than this field can be: it bounds the string at 300 characters and refuses every code point in Unicode categories Cc, Cf, Zl, Zp, Cs, Co and Cn, at pack time as well as at read time. See the `line` definition for what this schema can and cannot express.

# properties.bounded_outcome.description
ONE sentence stating what the recipient gets — the promise the Wisp is accountable to. Same untrusted-prose handling as `title`. A reader MUST NOT accept an outcome sentence that describes anything beyond the declared capabilities; the capabilities block, not this prose, is what is actually authorized.

# properties.publisher.description
The publisher's CLAIM about itself. Every field here is unverified self-description. reference/wisp.py reads only claimed_name (for the consent sentence) and ignores verification_state entirely when classifying a signature — deliberately, because a Wisp cannot certify its own publisher.

# properties.publisher.properties.publisher_id.description
Stable directory identifier used to locate an independently published trust record. Optional for unsigned Wisps. Presence is a lookup hint only; the independently acquired key and signature result establish the publisher.

# properties.publisher.properties.claimed_name.description
The name the publisher claims. Untrusted prose; render inert.

# properties.publisher.properties.claimed_contact.description
Optional contact string the publisher claims. NOT a channel the reader may use: v0 forbids network access, so a reader MUST NOT resolve, fetch, or open it. Display only.

# properties.publisher.properties.verification_state.description
A SELF-DECLARATION, never evidence. 'established-out-of-band' asserts only that the publisher believes a recipient may already hold a trust anchor for it; it does not make one exist. The recipient's own trust anchors decide, and signature_state() ignores this field. A reader MUST NOT display this value as if it were a verification result.

# properties.dna.description
The exact sorted list of top-level semantic DNA documents present. The reader enforces agreement in both directions and requires DNA/mission.md.

# properties.fidelity.description
Six independent, falsifiable fidelity statements. They are never averaged or collapsed into one score.

# properties.lineage.description
Optional signed ancestry. Each parent names an exact manifest digest; trust never transfers through lineage.

# properties.capabilities.description
The complete, closed set of powers the Wisp asks for. Exactly these seven keys, no others: an undeclared capability is an escalation and the Wisp is refused. load_manifest() enforces presence of all seven and network == 'forbidden'; this schema additionally pins each value and forbids extra keys. The v0 block is fixed — a Wisp needing different powers is not a v0 Wisp.

# properties.capabilities.properties.filesystem.description
Files may be created only inside one newly created workspace directory. No pre-existing directory is written to, ever.

# properties.capabilities.properties.read_scope.description
The reader may read the Wisp container and nothing else of the user's. No scanning of the surrounding filesystem.

# properties.capabilities.properties.write_scope.description
Writes are confined to the derived instance built from this Wisp. The original .wisp file is never modified.

# properties.capabilities.properties.browser.description
The reader may open exactly the local file it generated. Not a general browsing capability, and not permission to navigate anywhere else.

# properties.capabilities.properties.network.description
v0 FORBIDS network access during unfolding and during use. No fetch, no remote import, no telemetry, no dependency download, no font or analytics call, at build time or run time. load_manifest() refuses any other value outright.

# properties.capabilities.properties.shell.description
Shell use is limited to producing the generated files. No package installation, no privilege change, no command whose effect leaves the workspace.

# properties.capabilities.properties.persistence.description
State kept is the user's own progress, stored locally in the workspace. Nothing is transmitted; there is nowhere to transmit it to.

# properties.privacy.description
What the phenotype will hold about the user. These are publisher declarations; the capabilities block and the absence of any network channel are what actually constrain behaviour.

# properties.privacy.properties.collects.description
Plain-language categories of user input the experience records (e.g. 'free-text reflections', 'scale ratings'). An empty array means the experience records nothing. This list describes local storage only — collection here never implies transmission, because transmission is impossible under network: forbidden.

# properties.privacy.properties.storage.description
Where the collected data actually lives, in plain language, INCLUDING the parts that are inconvenient to say. The bound is 600 rather than one tight sentence because the truthful answer for a local HTML experience is not short: the page is a file in the new workspace, but the answers are held by the browser's storage for that file's origin — in that browser profile, on that device, NOT inside the folder the person was shown — and on a file: origin that storage is frequently unavailable, in which case state survives only until the tab closes. A publisher who cannot fit the truth in the field will write a comfortable half-truth instead, so the field is sized for the truth. MUST NOT describe any location outside the person's own device.

# properties.privacy.properties.telemetry.description
No telemetry, in any form, ever, in v0. The value is pinned so that a telemetry-bearing Wisp cannot be schema-valid.

# properties.privacy.properties.network_calls.description
Restates capabilities.network at the privacy layer so a privacy reviewer reading only this block reaches the same conclusion. A manifest whose two statements disagree is malformed.

# properties.projections.description
The projections the publisher PREFERS, most capable first. This is a preference ordering, not an assertion about the host: reasoning capability is not tool capability, and a Wisp may never claim the reader can write files, open a browser, or run a shell. The reader probes its own capabilities and descends to the best projection it can actually perform — local-static-html, then ai-artifact-html, then conversation, then read-only. read-only is always achievable, so a reader is never forced to refuse for lack of capability. load_manifest() requires a non-empty array of known values; it does not require any particular one.

# properties.entrypoint.description
The container-relative file a reader opens first to begin unfolding — normally UNFOLD.md. MUST exist in the container and (by the closure rule) therefore in the inventory; verify() checks container presence. Never WISP.json: the containerPath definition excludes the manifest, since a manifest cannot be its own entrypoint.

# properties.inventory.description
Every payload file in the container, with its digest and exact byte count. This is the content-integrity fact and nothing else — a perfect inventory match proves the bytes are the bytes the manifest describes, and proves nothing whatsoever about who wrote them.

MUST be sorted ascending by `path` and paths MUST be unique (neither is expressible here; validate_inventory() enforces both). MUST NOT list WISP.json or SIGNATURE/manifest.sig. maxItems is 510 because the container profile allows at most 512 entries and two of them are the manifest and the detached signature. `uniqueItems` here only rejects wholly identical rows; two rows sharing a path with different digests are caught by validate_inventory(), not by this schema.

# properties.inventory.allOf[].$comment
The author's build proposal is what a cold-started reader actually reads, and a Wisp without one cannot be opened. PROTOCOL.md 3.3: `OPEN.md` is the wisp/0 name and `UNFOLD.md` is the transitional one; a reader MUST accept either and a writer MUST emit `OPEN.md`. This schema previously required `UNFOLD.md` specifically, which made every conforming wisp/0 writer produce a manifest this schema refused.

# properties.inventory.allOf[].$comment
PURPOSE.md carries the bounded outcome in full prose. Required for the same reason.

# properties.inventory.$comment
EXPERIENCE.json is deliberately NOT required here. PROTOCOL.md 3.4: a Wisp whose outcome is a browser experience declares it, and a Wisp whose outcome is a capability, a procedure, a configuration or a transfer does not, and MUST NOT ship an empty or placeholder one to satisfy a shape. A manifest with no EXPERIENCE.json MUST NOT list `local-static-html` or `ai-artifact-html` in `projections`, because those two rungs would have nothing to build from; that constraint is not expressible here and is enforced by the reader.

# properties.identity.description
Digests of the three files that define what this Wisp IS, lifted out of the inventory so a reader can compare identity without walking the whole list. Each value MUST equal the digest of the named file; verify() checks this and reports 'identity: <n>_digest does not match <file>' otherwise.

null means the named file is absent, and two of the three can legitimately be absent in wisp/0. `experience_digest` is null for any Wisp that ships no EXPERIENCE.json, which PROTOCOL.md 3.4 makes optional. `unfold_digest` is null for a Wisp that names its build proposal `OPEN.md` rather than `UNFOLD.md`, because build_manifest() computes this field from the transitional name only; that is a KNOWN NARROWNESS of the reference and not an integrity gap, since OPEN.md is inventoried like every other file and is therefore covered by the manifest digest and the signature. A non-null value that disagrees with the named file's bytes is always a refusal.

What a null here does NOT mean: that the file is uncovered. This block is a convenience lift-out of the inventory, never a substitute for it.

# properties.signature.description
Where to find the detached signature, how it was made, and whether one was attached at all. Presence of this block asserts nothing about validity: it is metadata about a possible signature, not a signature. signature_state() refuses any algorithm or namespace other than the pinned values rather than attempting a fallback.

# properties.signature.properties.algorithm.description
Ed25519 over the canonical manifest bytes (RFC 8032). One algorithm only in v0 — a crypto registry is an explicit non-goal, and a negotiable algorithm field is a downgrade surface.

# properties.signature.properties.namespace.description
Domain separation tag. It scopes what a valid signature MEANS: these bytes are a Wisp v0 manifest. It prevents a signature made for another purpose from being replayed as a Wisp signature.

# properties.signature.properties.file.description
Container path of the detached signature. Detached, and excluded from the inventory, because the signature covers the manifest and so cannot be described by it.

# properties.signature.properties.expected.description
Whether the publisher attached a signature when this manifest was written. It exists to make SILENT SIGNATURE STRIPPING detectable: the flag lives INSIDE the signed bytes, so an attacker who deletes SIGNATURE/manifest.sig cannot also clear this flag without changing the canonical manifest — which changes the Wisp's identity. signature_state() reports 'declared-but-missing' for expected: true with no signature file, which is a tampering signal rather than a plain unsigned Wisp. false means the Wisp was published unsigned, and an unsigned Wisp is a normal, honest thing to be: a signature proves bytes, never a publisher. build_manifest() sets this from whether the pack actually signed.

# properties.expansion.description
Declared expansion cost, so a reader can refuse BEFORE allocating. A reader MUST still enforce its own limits against observed bytes and never trust these numbers — they are the publisher's claim, checked against reality by validate_inventory().

# properties.expansion.properties.file_count.description
MUST equal the number of inventory entries. validate_inventory() reports a mismatch.

# properties.expansion.properties.total_bytes.description
MUST equal the sum of inventory[].bytes. Ceiling is LIMIT_TOTAL_EXPANDED_BYTES (64 MiB).

# properties.expansion.properties.max_entry_bytes.description
The publisher's declared per-entry ceiling. MUST NOT exceed the reader's own LIMIT_ENTRY_BYTES (16 MiB). build_manifest() emits exactly 16777216; NOT ESTABLISHED: no code path in reference/wisp.py currently validates this field, so it is advisory until a reader checks it.

# properties.compatibility.description
The profile a reader must implement to read this Wisp. Every value is pinned in v0. The block exists precisely so a later version can vary the transport without touching identity — identity is this manifest, never the container bytes.

# properties.compatibility.properties.container.description
Transport profile: ZIP with only the STORED and DEFLATE methods. Any other compression method, encryption bit, directory entry, symlink or special-file entry is refused.

# properties.compatibility.properties.zip64.description
ZIP64 extensions are outside the profile. A reader refuses a container carrying the ZIP64 end-of-central-directory locator.

# properties.compatibility.properties.canonical_json.description
RFC 8785 JCS with the published errata (duplicate keys rejected, never merged) and the deliberate integers-only restriction: non-integer numbers are refused because ECMAScript double formatting is a canonicalization-ambiguity surface. Applies to the manifest and the signature file. Payload files are hashed as bytes and are unaffected.

# properties.compatibility.properties.min_reader.description
Lowest reader version that can read this Wisp.

# properties.resources.description
profile `resources/0` ONLY, and REQUIRED there. One row per file under `RESOURCES/`, classified so a recipient can read what they are being handed before they decide.

AGREEMENT IN BOTH DIRECTIONS, not expressible here and enforced by validate_resource_inventory(): a file present under `RESOURCES/` but absent from this list refuses the container, and a row here naming a file the container does not carry refuses it too. Rows MUST also be sorted ascending by `path` and unique, which JSON Schema cannot assert over an object-valued array. Every resource is separately inventoried in `inventory[]` like any other file; this list adds classification, it does not replace the byte inventory.

# properties.execution.description
profile `resources/0` ONLY, and REQUIRED there. The quarantine statement and the author's proposal, in one block.

TRUST TO UNFOLD IS TRUST TO INSPECT AND CONSTRUCT. Nothing in this block authorizes anything: a reader that opened a resources/0 Wisp has permission to read and to build, never to run. Running any resource is an ordinary, separate permission asked at the moment of use, per resource, by whatever surface would do the running — which is never this tool.

# properties.execution.properties.default.description
The quarantine, stated in the manifest and pinned to a single value. There is deliberately NO value here that means 'run automatically', and a publisher cannot introduce one: the enum has exactly one member, so any other value refuses the container.

# properties.execution.properties.plan.description
A least-privilege description of what a HUMAN would run, per resource. READ AS DATA: it is prose and structured fields a person weighs, never a script a reader executes and never a command line this tooling assembles. An empty array is a valid declaration that the publisher proposes no step at all. Each `resource` MUST name a row of `resources[]`; enforced by validate_resources_declaration(), not by this schema.

# properties.materials.description
profile `resources/0` ONLY, and REQUIRED there. An SPDX-lite list of the third-party dependencies the resources reference.

AN ABSENT LIST IS A REFUSAL; AN EMPTY LIST IS A VALID DECLARATION that the resources reference nothing third-party. The distinction is the whole point: silence and 'none' are different claims, and only one of them is checkable.

# allOf[].$comment
The profile gate. resources/0 REQUIRES the three declaration blocks; declarative/0 forbids all three, because a Wisp that carries no RESOURCES/ has nothing to classify, quarantine, plan or account materials for, and a stray block would be a claim nothing checks. reference/wisp.py enforces the same rule in validate_resources_declaration().

# $defs.line.description
One line of display prose. Refuses C0 and C1 controls, DEL, soft hyphen, the bidirectional overrides and isolates, zero-width characters, the line and paragraph separators, the invisible operators and the byte-order mark — every construct that lets a string lie about its own shape and restructure the consent statement it is interpolated into.

WHAT THIS PATTERN CANNOT EXPRESS: JSON Schema regular expressions cannot select by Unicode category, so unassigned (Cn), private-use (Co), surrogate (Cs) and any future format character are NOT excluded here. reference/wisp.py's check_display_text() rejects those categories wholesale and is the authority; this pattern is the portable approximation of it.

# $defs.digest.description
Lowercase hex SHA-256 with a mandatory 'sha256:' prefix. The prefix is required so the algorithm can never be inferred from the length of an unlabelled string.

# $defs.projection.description
The four descending phenotype projections, most capable first.

# $defs.containerPath.description
A path inside the container. Mirrors validate_container_path() and path_in_container_layout(): forward slashes only, relative, at most 8 components, at most 180 BYTES (this schema can only bound characters — a reader MUST measure UTF-8 bytes), no '.' or '..' component, no leading space and no trailing space or dot in any component (filesystem aliasing), no control characters, no Windows reserved device name in any component stem, and the whole path must sit at a protocol-defined location: one of UNFOLD.md, OPEN.md, PURPOSE.md, EXPERIENCE.json, or under DNA/, CONTENT/, RESOURCES/, TESTS/, ASSETS/, SIGNATURE/.

OPEN.md is the wisp/0 name for the author's build proposal and UNFOLD.md is the lab's original name for the same document; a reader accepts either.

RESOURCES/ is legal HERE as a path shape and is legal in a container only under profile `resources/0`. A declarative/0 Wisp carrying any RESOURCES/ entry is refused, by this schema's profile gate and by enforce_profile_layout().

WISP.json is deliberately NOT a legal containerPath. The manifest cannot list, target or point at itself.

NOT EXPRESSIBLE HERE, enforced by the reader: two paths that collide after NFC normalization and case folding are refused as a pair, because they can alias to one file on a normalizing or case-insensitive filesystem.

# $defs.resourcePath.description
A containerPath that is additionally inside RESOURCES/. Every archive suffix is refused by validate_container_path_resource() and every archive MAGIC by validate_resource_payload(); neither is expressible here, and the magic check is the one that matters, because a suffix list can be dodged by renaming.

# $defs.resourceEntry.description
One classified file under RESOURCES/. Every field is REQUIRED, including the ones whose honest value is 'unknown': `interpreter` is explicitly null rather than omitted, and an unknown licence is the SPDX `NOASSERTION` string rather than silence. An omitted field is a claim nobody made; a stated one is a claim that can be checked and contradicted.

# $defs.resourceEntry.properties.path.description
Container-relative path of this resource. MUST be present in the container and in inventory[].

# $defs.resourceEntry.properties.media_type.description
The publisher's declared media type, e.g. `text/x-shellscript` or `application/yaml`. A DECLARATION, not a verified fact: reference/wisp.py does not sniff content types, and a reader MUST NOT treat this string as evidence of what the bytes are. The checked facts about the bytes are the SHA-256 in inventory[] and the `executable` classification below.

# $defs.resourceEntry.properties.kind.description
Closed vocabulary. Unknown is refusal, never warning: a kind a reader does not understand is a resource it cannot describe to a person, and describing it wrongly is worse than refusing.

# $defs.resourceEntry.properties.interpreter.description
What a human would need in order to run this, e.g. `bash`, `python3`, or null for a file nothing interprets. NAMING AN INTERPRETER IS NOT A REQUEST TO USE ONE: no code path in reference/wisp.py reads this field into a command, and nothing here reaches a subprocess.

# $defs.resourceEntry.properties.license.description
SPDX licence identifier, or the SPDX `NOASSERTION` string when the licence is genuinely unknown. REQUIRED and never empty: a recipient reconstructing a workflow from these files needs to know what they are allowed to do with them, and an absent licence is the one answer that helps nobody.

# $defs.resourceEntry.properties.origin.description
Where this file came from, in the publisher's words: an upstream project, an internal repository, 'written for this Wisp'. UNTRUSTED PROSE and unverifiable — a provenance claim inside a Wisp is a self-declaration, exactly like publisher.claimed_name.

# $defs.resourceEntry.properties.executable.description
Whether these bytes are executable content. CHECKED IN THE DANGEROUS DIRECTION: validate_resource_inventory() reads the file and refuses the container if the bytes carry program magic (shebang, ELF, PE, Mach-O, JVM, wasm, DEX) while this says false. Over-declaring is free and is never refused; under-declaring is what makes a recipient relax, so it is not permitted.

`true` HERE MEANS 'THESE BYTES CAN BE RUN BY SOMETHING', NOT 'RUN THEM'. Nothing in this container is executed by the reader under any profile.

# $defs.executionStep.description
One step of the author's proposal for what a HUMAN might run, and what it would cost them. Read as data. Nothing here is executed, and no field is combined into a command line by any code path in reference/wisp.py.

# $defs.executionStep.properties.resource.description
The resource this step is about. MUST name a row of resources[]; enforced by validate_resources_declaration(), not here.

# $defs.executionStep.properties.purpose.description
What running it would accomplish, in the person's terms. Untrusted prose.

# $defs.executionStep.properties.command_shape.description
The SHAPE of the command a person would type, e.g. `bash RESOURCES/deploy.sh <environment>`. A description, never an executable string: reference/wisp.py prints it and does nothing else with it. A reader MUST NOT pass this value to a shell, and MUST render it as inert text.

# $defs.executionStep.properties.privileges.description
Least-privilege declaration: what the step would need, named one at a time. An EMPTY array is a valid declaration that it needs nothing beyond an ordinary user session. A request is not a grant — listing a privilege here obtains nothing.

# $defs.executionStep.properties.network.description
Whether running the step would reach the network. `declared-hosts` means the author says it talks to hosts named in their own prose; it is a CLAIM, and nothing verifies it. Note the scope: this describes the proposed human step, not the reader — reference/wisp.py itself never accesses the network in any command, under any profile.

# $defs.executionStep.properties.reversible.description
Whether the author believes the effect can be undone. A CLAIM by the publisher, not a checked property.

# $defs.executionStep.properties.verification.description
How a person would confirm the step did what it claimed. REQUIRED, because a proposal with no way to check the outcome is a request for trust rather than a plan.

# $defs.material.description
One third-party dependency the resources reference. SPDX-lite: enough for a recipient to see what else they would be pulling in, deliberately not a full SBOM. Every field is a publisher DECLARATION; none of them is resolved, fetched or verified, and no code path here reaches a package registry or the network.

# $defs.material.properties.version_constraint.description
The constraint as the resources express it, e.g. `^4.2.0` or `pinned 1.19.3`. A string to display, never parsed into a resolver.

# $defs.material.properties.license.description
SPDX identifier, or `NOASSERTION`. Never empty.

# $defs.material.properties.source.description
Where it comes from, in the publisher's words. A URL here is TEXT: no reader fetches it, and a recipient should treat it as a claim to check by hand.

# $defs.inventoryEntry.properties.path.description
Container-relative path of one payload file. SIGNATURE/manifest.sig is excluded: the signature is computed over the canonical manifest bytes, so a manifest containing the signature's digest could never be satisfied. WISP.json is excluded by containerPath itself.

# $defs.inventoryEntry.properties.sha256.description
SHA-256 of the file's exact bytes as stored. A match proves content integrity only.

# $defs.inventoryEntry.properties.bytes.description
Exact uncompressed byte length, checked against the bytes actually read. Ceiling is LIMIT_ENTRY_BYTES (16 MiB); a reader enforces its own limit before allocating, never this declaration.

# $defs.detachedSignatureFile.description
INFORMATIVE — the shape of SIGNATURE/manifest.sig, which is a separate document from this manifest and is NOT validated by this schema. Its bytes are RFC 8785 canonical JSON. signature_state() reads only `signature` and `public_key`; `algorithm` and `namespace` are matched against the manifest's signature block, and `note` is ignored.

A VALID SIGNATURE HERE PROVES BYTES, NOT PEOPLE. With no out-of-band trust anchor the honest classification is 'signed-unverified-publisher': the signature verifies over the manifest and the publisher remains NOT ESTABLISHED. A key carried inside a Wisp can never establish that Wisp's own publisher.

# $defs.detachedSignatureFile.properties.public_key.description
32-byte Ed25519 public key, lowercase hex.

# $defs.detachedSignatureFile.properties.signature.description
64-byte Ed25519 signature over the canonical manifest bytes, lowercase hex.

# $defs.detachedSignatureFile.properties.note.description
Free text. Ignored by verification. Untrusted prose; never authority.