# title
Wisp unfold record (UNFOLD_RECORD.json) — protocol v0

# description
OUTCOME EVIDENCE. What one reader did, once, with one Wisp, on one host.

WRITTEN OUTSIDE THE WISP. This record is created in the derived instance workspace, never inside the container. THE ORIGINAL .wisp FILE IS NEVER MODIFIED — not to append a record, not to mark it used, not to update a counter. A Wisp is immutable on receipt, and a reader that writes into it has left the protocol.

WHAT IT IS EVIDENCE OF, AND WHAT IT IS NOT. This document records the fifth of the five separate facts. It reports the other four rather than merging them:
  * content integrity — did the bytes match the manifest? (wisp.manifest_digest)
  * publisher identity — signature.publisher_established, TRUE ONLY when an out-of-band trust anchor matched. Never inferred from a bundled key.
  * user authorization — the consent block. A 'Trust and unfold' authorizes ONE BOUNDED LOCAL ACTION and nothing else. IT IS NOT AND NEVER BECOMES EVIDENCE OF PUBLISHER IDENTITY, and no consumer of this record may present it as such.
  * phenotype conformance — conformance.*, which says only that the built files satisfied the embedded assertions.
A reader assembling a summary MUST keep the four apart. Their conjunction is not a trust verdict.

SELF-REPORTED THROUGHOUT. The agent block, the capability probe, the network observation and both timestamps are the reader's own account of itself. There is no attestation mechanism in v0: nothing here is signed, countersigned or witnessed, and a record can be edited by anyone who can write the file. Treat it as a log, not a certificate.

TIMESTAMPS ARE NON-IDENTITY EVIDENCE. started_at and completed_at are readings of a local, unsynchronised, user-settable clock. They order events within one run. They establish no identity, no priority, no authorship and no existence-at-a-time; there is no trusted timestamping in v0.

THE RECORD CANNOT DESCRIBE ITSELF. UNFOLD_RECORD.json is absent from `artifacts` for the same reason the manifest is absent from its own inventory: a document cannot contain its own digest. Its own integrity is unattested, which is one more reason to read it as a log.

STATUS: EMITTED AND ENFORCED. reference/wisp.py's _build_into_workspace() writes exactly this document at the end of every unfold, and tests/test_schema_conformance.py validates a record produced by a real unfold against this file with a real Draft 2020-12 validator.

# properties.wisp.properties.wisp_id.description
The manifest's slug. A label chosen by the publisher, not an identity — two unrelated Wisps may share it.

# properties.wisp.properties.manifest_digest.description
SHA-256 of the CANONICAL manifest bytes. THIS IS THE IDENTITY OF THE WISP. Two runs whose manifest_digest matches concerned the same Wisp, whatever their containers looked like.

# properties.wisp.properties.container_digest.description
SHA-256 of the .wisp file as received, hashed before the unfold and re-hashed after it to prove the original was not modified. NOT the identity: re-packing the same Wisp can change these bytes without changing what the Wisp is.

# properties.wisp.properties.title.description
Copied from the manifest for human readability. UNTRUSTED PUBLISHER PROSE — render inert wherever this record is displayed.

# properties.signature.description
The honest classification produced by the reader, mirroring signature_state(). Mere presence of a signature means nothing; only the state means something, and most states mean the publisher is NOT established.

# properties.signature.properties.state.description
unsigned: no detached signature present, and the manifest did not claim one. declared-but-missing: the SIGNED manifest sets signature.expected true but no signature file is present — the signature was stripped after the manifest was written, which is a tampering signal rather than a plain unsigned Wisp. undeclared-signature: a signature file IS present while the manifest declares signature.expected false — the mirror image of stripping, and the more dangerous half, because reading a signature the manifest never acknowledged would let an unsigned Wisp acquire a publisher and, with a matching anchor, an established one. unsupported: algorithm or namespace outside the pinned profile — refused, never downgraded. invalid: malformed, or does not verify over the canonical manifest bytes. signed-unverified-publisher: cryptographically valid AND the publisher is NOT ESTABLISHED, because no out-of-band trust anchor was supplied — this is the normal state for a Wisp received cold, and the signature proves bytes only. signed-untrusted-key: valid, but the key is not among the supplied anchors. signed-trusted: valid AND the key matched an anchor the RECIPIENT supplied out of band — the only state in which publisher identity is established.

# properties.signature.properties.publisher_established.description
TRUE only when state is 'signed-trusted'. A key carried inside a Wisp can never establish that Wisp's own publisher, so no bundled material can raise this flag. The schema enforces the implication below.

# properties.signature.properties.key_id.description
Fingerprint of the public key that was checked: 'sha256:' plus the FIRST 32 HEX CHARACTERS of the key's SHA-256, exactly as signature_state() computes it. null when no key was present or readable. An identifier for correlating keys across records — never, on its own, an identity.

# properties.signature.properties.algorithm.description
Optional. The algorithm the manifest declared, echoed as found. null when absent. Recording an unsupported value is how a refusal stays diagnosable.

# properties.signature.properties.detail.description
Optional human-readable explanation of the state, as produced by the reader.

# properties.signature.properties.note.description
Optional standing caution written by the reader, so that a record read on its own still carries the distinction it depends on: integrity is not identity, and approval is not verification.

# properties.signature.allOf[].then.$comment
Publisher identity can be claimed established ONLY by a valid signature over the canonical manifest against a trust anchor the recipient supplied out of band.

# properties.consent.description
USER AUTHORIZATION — the third fact, recorded as it was asked and answered. It authorizes one bounded local action on this host at this moment. It confers no identity on the publisher, no ongoing permission, and no authority over anything outside granted_scope.

THE GRANT IS BOUND, AND THE BINDING IS RECORDED. consent_summary() issues a grant carrying granted=false plus manifest_digest, capabilities, workspace, projection and the sentence; a caller who obtained a human yes sets granted=true and hands it back. check_grant() refuses unless every term still matches the action about to be taken, BEFORE the workspace exists. The four binding terms are recorded here so a later reviewer can see that the approval that was given is the action that was taken, and cannot have been replayed against a different Wisp, widened after the fact, or redirected to another directory.

# properties.consent.properties.sentence.description
The VERBATIM consent statement the person was shown, carried inside the grant so that what was authorized can be audited against what was done. It is generated by the reader from verified envelope fields plus the reader's own planned actions — never from Wisp prose asking to be trusted.

# properties.consent.properties.granted.description
The answer the grant carries. A record only exists in a workspace, and in v0 no workspace exists unless the grant was affirmative, so a conforming reference record always shows true. false remains a first-class outcome for readers that log refusals elsewhere: it means nothing was built, which the schema enforces below.

# properties.consent.properties.manifest_digest.description
The Wisp identity the approval was bound to. MUST equal wisp.manifest_digest — an approval for one artifact is not an approval for another.

# properties.consent.properties.workspace.description
The exact directory the approval authorized, as an absolute path. Writing anywhere else is outside the grant. MAY identify the person by their home directory, so a record shared as evidence SHOULD be redacted. NULL for a projection that writes no files: ai-artifact-html, conversation and read-only create nothing, so naming a directory in their consent would describe an authority nobody asked for — and the consent copy for those rungs must not mention a folder either.

# properties.consent.properties.projection.description
The projection the approval was bound to. MUST equal projection.selected: a person who approved a local page did not thereby approve some other rendering.

# properties.consent.properties.proof.description
What the grant actually proves, in the reader's own words, INCLUDING WHAT IT CANNOT PROVE. A matching grant establishes that the caller held an approval bound to this artifact, authority, destination and projection. It cannot establish that a human spoke — no v0 mechanism can — and a record that omits that limitation is overclaiming.

# properties.consent.properties.granted_scope.description
The capability block the person authorized, copied from the manifest at the moment of the decision and re-compared by check_grant() before anything was built. Recorded in full so a later reviewer can see the boundary without re-opening the Wisp. If the reader exceeded any of these, the run was non-conforming regardless of what this record says.

# properties.consent.properties.decisions_requested.description
Exactly one. The protocol presents ONE bounded consent statement; a reader that asks again — for a widened scope, a retry, or a second phase — has left v0. Pinned so that drift is a schema failure rather than a UX opinion.

# properties.agent.description
The reader's SELF-DESCRIPTION. Every field is unverifiable: there is no attestation, no signed runtime, no way for this record to prove which system wrote it. Useful for correlating trials; never evidence of provenance.

# properties.agent.properties.name.description
Optional. Tool or product name as the agent reports it.

# properties.agent.properties.model.description
Optional. Model identifier as the agent reports it. WARNING FOR TRIAL EVIDENCE: two runs by the same model family are a SIMULATION of interoperability, never proof of it. Cross-provider interoperability is NOT ESTABLISHED until independent providers produce conforming records.

# properties.agent.properties.host.description
Optional. Operating environment as the agent reports it. SHOULD NOT carry a username, hostname or path that identifies the person — this record may be shared as evidence.

# properties.agent.properties.self_reported.description
Pinned true. There is no mechanism by which these fields could be anything else in v0, and pinning it prevents a future record from silently implying verification.

# properties.capability_probe.description
What the READER observed about ITS OWN abilities, before choosing a projection. These are the reader's findings, never the Wisp's claims: a Wisp may PREFER a projection but may never assert that the host can perform it, and reasoning capability is not tool capability. A reader MUST probe rather than assume, and MUST record false rather than optimistic when unsure.

can_run_shell IS DELIBERATELY ABSENT. An earlier record shape carried it and the reference hardcoded it true — a probe result that was never probed. A capability nothing measures does not belong in an evidence document; the projection ladder does not consult one, and a shell is not needed to write a file.

# properties.capability_probe.properties.can_write_files.description
Can create the new isolated workspace directory and write files into it. Probed by writing inside the workspace once it exists, and by a permission check on the parent before it does — never by writing outside the authorized scope.

# properties.capability_probe.properties.can_open_browser.description
Can open a local file in a browser on this host. ALWAYS probed, even when the reader has been told not to open anything: whether a capability exists and whether it is exercised are different facts, and conflating them let a record claim local-static-html while reporting no browser.

# properties.capability_probe.properties.can_produce_artifact.description
Can emit a self-contained rendered artifact through its own surface, without a filesystem.

# properties.capability_probe.properties.can_converse.description
Can deliver the experience as a conversation. The conversation rung requires it; read-only requires nothing, so no host is ever forced to refuse for lack of capability.

# properties.capability_probe.properties.detail.description
Optional. Per-capability notes from the probe — the errno of a failed write, the exception a browser lookup raised, the directory a permission check was run against. Diagnostics, so a false is explainable instead of mysterious.

# properties.capability_probe.properties.note.description
Optional standing caution from the reader about how these values were obtained.

# properties.projection.description
Which phenotype was actually built, and why. The four projections descend: local-static-html, ai-artifact-html, conversation, read-only. A reader takes the most capable rung the Wisp offers that its OWN probe supports. read-only is always achievable, so lack of capability degrades the phenotype and never becomes a refusal.

# properties.projection.properties.selected.description
The projection built. MUST be a member of `considered`, and MUST equal consent.projection (neither is expressible here; check_grant() enforces the second).

# properties.projection.properties.considered.description
The projections the Wisp offered, most capable first. Recording the ones passed over is what makes the descent auditable.

# properties.projection.properties.declined.description
One entry per rung that was passed over, with the reason. Empty when the most capable offered projection was available. Named by probe key so the cause is machine-checkable rather than prose.

# properties.projection.properties.declined.items.properties.missing.description
The capability_probe keys this rung required and the host did not have. local-static-html requires BOTH can_write_files and can_open_browser: a local page nobody can open is not a delivered experience.

# properties.projection.properties.declined.items.properties.reason.description
The same fact in plain language, for a person reading the record.

# properties.artifacts.description
Every file the reader created, with its digest — the reader's own account of what it wrote, so the blast radius of the authorized action is inspectable. All paths are inside the derived workspace; nothing outside it was written. UNFOLD_RECORD.json itself is excluded (it cannot contain its own digest). Empty when the projection produced no files. The digest recorded here is re-checked before the page is opened, so a page edited after the build is refused rather than shown.

# properties.artifacts.items.properties.path.description
Path relative to the workspace root. Never absolute, never containing '..' — the record must not leak the user's directory layout, and a containment escape is a protocol violation, not a path to be logged.

# properties.artifacts.items.properties.sha256.description
SHA-256 of the file as written, so the phenotype can be re-verified later.

# properties.artifacts.items.properties.kind.description
What the file is, for review — a media classification, not a role. The v0 phenotype is one self-contained 'html' file. 'script' would mean a separate generated JavaScript file: it is GENERATED BY THE READER from typed declarative primitives, never copied from the Wisp — a Wisp may not contain executable code, and a reader that found any must refuse rather than emit it.

# properties.conformance.description
PHENOTYPE CONFORMANCE — the fourth fact, and the narrowest. It says the built files satisfied the embedded acceptance assertions. It says nothing about whether the experience is good, safe, accurate, or from whom it came. The assertions are the PUBLISHER's, so a green result is the publisher's own bar, met.

LAYERED, AND `not-run` IS NOT `pass`. The counts and details above describe the STRUCTURAL layer only — the publisher's acceptance assertions, which are statements about the shape of a document. A 625-byte page with five empty <main> elements, six dummy buttons and the required phrases as visible text satisfied all 23 of them while implementing no journey, no state, no validation, no branching, no result, no persistence, no import, no export and no reset. `layers` therefore separates four different claims, each carrying its own verdict, and a layer that did not execute reports `not-run` rather than being counted as satisfied. `conforming` is true only when every layer REQUIRED for the selected projection passed; `unproven_layers` names the ones that were never executed, and `open` refuses on either list unless the caller explicitly accepts the risk.

# properties.conformance.properties.acceptance_tests_run.description
Assertions actually evaluated. MUST be 0 if the Wisp carried none — a reader MUST NOT report a test it did not run, and a Wisp with no tests is unproven, not passing.

# properties.conformance.properties.failed.description
Any 'must' assertion failing means the phenotype is NON-CONFORMING: reference/wisp.py surfaces it in warnings, exits non-zero, and refuses to open the page.

# properties.conformance.properties.details.description
Per-assertion outcome, in the order the Wisp declared them. passed + failed may be less than acceptance_tests_run only if a reader records outcomes this reference does not produce; the difference MUST be visible here.

# properties.conformance.properties.details.items.properties.id.description
The assertion's id from the Wisp's TESTS/acceptance.json.

# properties.conformance.properties.details.items.properties.result.description
'skipped' means the reader could not evaluate it — which is not a pass and MUST NOT be counted as one. reference/wisp.py emits only 'pass' and 'fail': an assertion it cannot evaluate, including one whose target is missing or escapes the workspace, fails.

# properties.conformance.properties.details.items.properties.target.description
Optional. The workspace file the assertion examined.

# properties.conformance.properties.details.items.properties.message.description
Optional. The assertion's authored message — UNTRUSTED PUBLISHER PROSE, displayed inert and never acted on.

# properties.conformance.properties.details.items.properties.detail.description
Optional. What the reader actually OBSERVED — the digest it computed, the element count it found, the reason a target could not be read. The publisher wrote `message`; the reader wrote this, and only this is evidence.

# properties.conformance.properties.layers.description
One entry per layer, in a fixed order: model, structural, behavioural-model, behavioural-browser. model asks whether the Wisp declared a coherent experience; structural asks whether the built artifact has the shape the publisher's contract requires; behavioural-model asks whether the artifact CARRIES the Wisp's journey and whether that journey answers correctly to the READER's own typed interaction vectors (the reader's, not the Wisp's — a counterfeit can omit an assertion and cannot omit the reader's requirement); behavioural-browser asks whether those vectors were executed against the real page in a real browser, which is the only place focus, accessible descriptions, live announcements, storage quarantine and download behaviour can be decided.

# properties.conformance.properties.layers.items.properties.result.description
A layer with no checks is `not-run`, never `pass`: all([]) is true, and a report built that way calls an empty run a clean one.

# properties.conformance.properties.layers.items.properties.reason.description
Why the layer is not-run, or how it was executed.

# properties.conformance.properties.required.description
The layers that must pass for the SELECTED projection. A conversation has no artifact to inspect structurally and no browser to run in, so requiring those of it would be theatre; a local page opened in a browser is the one rung whose behaviour a browser decides.

# properties.conformance.properties.unproven_layers.description
Required layers that did NOT run. Distinguished from failures because the remedy differs: a failure is a defect, an unproven layer is missing evidence. Neither may be read as a pass.

# properties.conformance.properties.conforming.description
True only when every required layer passed. This is the single field a consumer should read before presenting a build as working.

# properties.network.description
The zero-network commitment and what was actually observed. Declaration and observation are recorded separately because they are different claims: one is policy, the other is evidence.

# properties.network.properties.declared.description
v0 forbids network access during unfolding and during use. Pinned: a record declaring anything else describes a run that was not a v0 unfold.

# properties.network.properties.observed.description
'unknown' is the HONEST DEFAULT and is what reference/wisp.py writes: it opens no sockets and statically inspects the page it generated, but it does not instrument the host's egress, and static inspection cannot support 'none'. 'none' asserts that egress was actively monitored and nothing left the host — it requires a real technique named in `method`. 'attempted' means a call was made or blocked, which is a protocol violation and MUST be surfaced in `warnings` and to the user.

# properties.network.properties.method.description
How the observation was made, in plain language — e.g. 'sandbox with egress denied and blocked-connection log reviewed', 'static inspection of generated files only; egress not instrumented'. Required even for 'unknown', so the limits of the evidence are always stated rather than left to inference.

# properties.output_location.description
Where the derived instance lives, as the reader would describe it to the user. null when nothing was written (refusal, or a projection with no filesystem). MAY be an absolute host path; a record intended for sharing SHOULD carry a relative or redacted form, since a path can identify a person.

# properties.warnings.description
Things that went wrong or nearly did, in the reader's own words. A warning MUST NOT be downgraded into silence: anything that changed what the user got belongs here AND in what the user was told.

# properties.limitations.description
What this run does NOT establish. Non-empty in v0, always — at minimum, that publisher identity was not established unless signature.publisher_established is true, and that a trial by a single model or provider is a simulation rather than interoperability evidence. Stating limitations is the mechanism that keeps this record from being read as a certificate.

# properties.started_at.description
When the run began. See the non-identity note in this schema's description.

# properties.completed_at.description
When the run ended, whether it succeeded, failed or was refused. MUST NOT be earlier than started_at (not expressible here).

# properties.evidence_note.description
The standing caution about the timestamps above, written into the document itself so that a record read in isolation still carries it: they are evidence only and never contribute to any identity or digest.

# properties.source_path.description
TRANSPORT EVIDENCE about the .wisp file on disk, observed after the build finished. Deliberately NOT an identity and deliberately not a gate: the build read one immutable byte snapshot taken before verification, so this comparison can only report whether someone touched the path while the reader worked. It can never select which bytes were used. An earlier design re-opened the path to build from, which made this comparison load-bearing and left a window in which the file could be swapped and restored.

# properties.source_path.properties.path_digest_after_build.description
SHA-256 of the path, read in bounded chunks after the build. Null if the path became unreadable or grew past the container limit.

# properties.source_path.properties.matches_verified_snapshot.description
True when the path still holds exactly the snapshot that was verified and built from. False is a tampering signal about the path, not a defect in the artifact.

# allOf[].then.$comment
Nothing is written anywhere before consent. A refused unfold therefore produced no artifacts, has no output location, and ran no acceptance tests — a record claiming otherwise describes a reader that acted without authorization. In this reference the workspace itself is only created after check_grant() passes, and is removed again if anything later fails, so a refusal leaves no record at all.

# $defs.line.description
One line of inert text: no control characters, no newlines, no bidirectional overrides, no zero-width or invisible characters. Bounded so untrusted publisher prose cannot restructure a log line or a displayed summary.

# $defs.prose.description
Multi-line inert text: newlines permitted, every other control, bidirectional-override, zero-width and invisible character refused.

# $defs.digest.description
Lowercase hex SHA-256 with a mandatory 'sha256:' prefix.

# $defs.timestamp.description
ISO 8601 / RFC 3339 instant. An explicit offset or 'Z' is REQUIRED — a local time without one is unorderable across hosts. NON-IDENTITY EVIDENCE: a local clock reading, self-reported and unwitnessed.

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

# $defs.workspacePath.description
A path relative to the derived workspace root: forward slashes only, at most 8 components, no absolute path, no '.' or '..', no control characters, no leading space and no trailing space or dot in any component. A reader MUST additionally resolve and re-check containment before writing, because a pattern cannot see symlinks.

# $defs.assertionKind.description
The acceptance assertion vocabulary, mirroring urn:wisp:schema:acceptance:0 and reference/wisp.py's ASSERTION_KINDS exactly. Duplicated inline rather than $ref'd across files: v0 forbids network access, so no schema may depend on resolving a remote identifier, and every schema must validate standalone and offline.

# $defs.capabilities.description
The fixed v0 capability block, duplicated here from urn:wisp:schema:manifest:0 for the same offline-standalone reason as assertionKind. The values a person authorized are recorded literally, not by reference.