LoomSeal woven LS mark

The LoomSeal Specification

Status: v0.1 draft, 2026.

loomseal.com · The spec

The canonical file is FORMAT.md in the repository; this page is the same text, typeset. The machine-readable schema is loomseal-bundle.schema.json.

LoomSeal is one portable document shape for proof. A LoomSeal bundle carries claims about what a system did, the evidence digests behind those claims, the hash-chain coordinates that fix each claim in an append-only history, the external anchors that fix that history in time, and a signature from the producer. A third party verifies a bundle offline with an open verifier, without trusting KordLoom and without contacting KordLoom.

The name is the claim: a seal does not prevent tampering, it reveals it, and that is exactly what verification checks.

KordLoom products speak it, one verifier checks it:

This spec generalizes constructions KordLoom already ships. It invents no new cryptography. SwitchTender chains audit entries with SHA-256 and already exports the chain signed with ed25519 for offline verification. LoomSeal is the shared envelope around those primitives.

Design rules

  1. Offline verification. A bundle plus the open verifier is sufficient. No server calls, no account, no KordLoom involvement.
  2. One file. A bundle is a single JSON document. Evidence artifacts may travel beside it or stay home; the bundle carries their digests either way.
  3. Boring cryptography only. SHA-256, HMAC-SHA256, ed25519. Nothing exotic, no blockchain, no consensus, no tokens.
  4. Deterministic. Same bundle, same verifier, same verdict, forever.
  5. Honest claims. The format documents exactly what verification proves and what it cannot prove. Tamper-evident, not tamper-proof.
  6. Shipped reality wins. Where this spec and released product behavior diverge, the product is the reference for its own chain profile and the spec updates.

The bundle

A bundle is a JSON object with these members:

MemberRequiredPurpose
loomsealyesFormat version, "0.1"
bundle_idyesProducer-assigned identifier for this bundle
created_atyesRFC 3339 UTC time the bundle was assembled
produceryesWho emitted it: product, version, install, key
subjectyesWhat the claims are about
chainnoChain profile, parameters, and head (level 2 and up)
claimsyesThe claims, each with payload, evidence, chain coordinates
anchorsnoExternal anchor records (level 3)
signaturesyesAt least one producer signature over the bundle

The subject vocabulary this format knows is url, fleet, repo, agent, host, run, and org. A verifier reports a subject type outside it by name and continues. Every shipped verifier holds this same list, and a conformance vector per known type pins that they do.

Example, a bundle at level 3 on the generic profile (digests are illustrative):

{
  "loomseal": "0.1",
  "bundle_id": "lsb_9c41d0a2b7e3",
  "created_at": "2026-07-27T15:04:05Z",
  "producer": {
    "product": "switchtender",
    "product_version": "1.33.0",
    "install_id": "in_7f3a9b2c",
    "public_key": "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=",
    "key_id": "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"
  },
  "subject": {
    "type": "host",
    "id": "web-07.fleet.example.com"
  },
  "chain": {
    "profile": "loomseal-chain-v1",
    "keyed": true,
    "params": { "install_id": "in_7f3a9b2c" },
    "head": {
      "seq": 18211,
      "link": "fcde2b2edba56bf408601fb721fe9b5c338d10ee429ea04fae5511b68fbf8fb9"
    }
  },
  "claims": [
    {
      "type": "switchtender.audit/1",
      "at": "2026-07-27T14:00:11Z",
      "payload": {
        "actor": "u_4b1e",
        "method": "POST",
        "path": "/api/jobs/deploy-web",
        "outcome": "applied",
        "elapsed_ns": 412000000,
        "error": ""
      },
      "evidence": [
        {
          "role": "transcript",
          "digest": "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
          "media_type": "application/json",
          "present": false
        }
      ],
      "verdict": {
        "policy": "fleet-change-control/3",
        "policy_digest": "sha256:7d865e959b2466918c9863afca942d0fb89d7c9ac0c99bafc3749504ded97730",
        "decision": "notify",
        "detail": "changed hosts: 4 of 210"
      },
      "chain": {
        "seq": 18209,
        "prev": "5df6e0e2761359d30a8275058e299fcc0381534545f55cf43e41983f5d4c9456",
        "link": "6b23c0d5f35d1b11f9b683f0b0a617355deb11277d91ae091d399c655b87940d"
      }
    }
  ],
  "anchors": [
    {
      "type": "git",
      "seq": 18000,
      "link": "1f2d3c4b5a69788766554433221100ffeeddccbbaa99887766554433221100ff",
      "at": "2026-07-26T00:00:00Z",
      "ref": "https://github.com/acme/audit-anchors/commit/8f14e45fceea167a"
    }
  ],
  "signatures": [
    {
      "key_id": "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
      "alg": "ed25519",
      "sig": "yA8leGOa8kM8y7yQ2P1cCcieJfNDpDbIcrXYRlpn0P5cAOSYyzOxs2Yr1JhBpVL8..."
    }
  ]
}

Canonical form and digests

The canonical form of a bundle is the RFC 8785 (JSON Canonicalization Scheme) serialization of the bundle object. Object keys are ordered by their UTF-16 code units, which RFC 8785 requires and which differs from code point order above the basic multilingual plane. Object keys must be unique within an object; a repeated key has no canonical form and the bundle is rejected. Strings must be valid UTF-8, and every \u escape must denote a valid Unicode scalar value. A lone surrogate, whether written as a \uD800-through-\uDFFF escape or as raw bytes, is rejected at parse and never coerced to the replacement character; coercion would let two different documents share one canonical form and one signature. Digest strings are sha256: followed by 64 lowercase hex characters. Times are UTC, in the one form stated below. Numbers in a bundle are written as plain integer literals with absolute value at most 2^53; fractions, exponents, and larger magnitudes are invalid, because RFC 8785 serializes numbers as IEEE doubles and those forms do not round-trip.

Every member this format defines holds the JSON type the schema gives it, and a value of another type, such as a number where a string belongs or a boolean where an integer belongs, is rejected at parse rather than read as that type. No member this format defines takes null, and no array it defines holds one. A verifier rejects a null at parse rather than reading it as an empty, false, or absent value, because a null prev read as the empty string would recompute as genesis. The exceptions are a disclosure's value, which is the revealed field itself and may be any JSON, and the members of a claim payload, which belong to the producer. Every base64 value, from producer.public_key and each sig to an anchor's proof and an attestation's key, is RFC 4648 standard encoding with its padding and nothing else. A line break, whitespace, or any other byte outside the alphabet is rejected rather than skipped, because a decoder that skips such bytes gives one key or signature many spellings. A bundle or presentation nests no deeper than 3200 levels, where an array or an object is one level and the outermost object is the first. The bound is set by the shipped verifier with the least stack, the browser build, so every verifier reads a document within it to a verdict, and a deeper document is rejected at parse before any recursive reader sees it. A string that holds JSON text, such as an outcome record, is text to this bound, so a verifier that reads such text bounds its depth without recursion before any recursive reader sees it. The type, null, and base64 rules refuse only inputs the schema or RFC 4648 already forbids. Vectors pin each of them: the type refusals in every vector whose name begins with type- and in params-number, the null refusals in prev-null, attestation-null, disclosure-null, and params-null, the base64 refusals in every vector whose name ends in line-break, and the bound in nesting-past-bound and present-past-bound, which are refused, and nesting-3000-deep, nesting-at-bound, and switchtender-outcome-deep, which verify.

Every time a bundle or a presentation carries, created_at, each claim's at, an anchor's at, a claim attestation's and a head attestation's at, and a presentation's created_at, takes one form and no other: YYYY-MM-DDTHH:MM:SS, then an optional period and one or more digits of fractional second, then Z, as in 2026-07-27T15:00:00Z and 2026-07-27T15:00:00.123456789Z. Every digit is ASCII and every field has exactly the width shown. The year runs from 0001 to 9999, the month from 01 to 12, and the day within its month, February 29 only in a leap year. The hour runs from 00 to 23, and the minute and the second from 00 to 59, so a leap second written as 60 is rejected. The T and the Z are upper case. This is the RFC 3339 date-time production narrowed to UTC, which is the form every producer this format describes writes. A verifier rejects a bundle carrying any other string at parse, under every profile, before any profile reads the time: a numeric offset, +00:00 included, a space or a lower case t in place of the T, a lower case z, a missing Z, year 0000, a one-digit field, a comma before the fraction, and a field out of range. A presentation whose created_at takes any other form fails the presentation check. An attestation that carries at carries a time in this form, so an empty at is rejected rather than read as no time.

A verifier reads every time to the whole microsecond, whatever precision its own time type keeps, and drops any finer digits toward the earlier instant, never rounding them. Every comparison a verdict rests on is made between times read that way: a claim's at against a timestamp token's genTime, that genTime against its certificate's validity window, and one span beat against the next. No verifier can then reach a verdict on a digit another one drops. A profile that hashes a time still hashes every digit as written.

The rule is this narrow because general date parsers each read a different superset of RFC 3339, one a space for the T, another year 0000, another any offset, and two verifiers built on two of them reached different verdicts on one signed bundle. Vectors pin it: every vector whose name begins with time-, created-at-one-digit-hour, created-at-comma-fraction, created-at-offset-hour-24, created-at-offset-minute-60, and the attestation and presentation vectors whose names end in one-digit-hour, comma-fraction, offset, or lowercase-z. Of these, time-fraction, time-fraction-twelve-digits, time-year-0001, and time-year-9999 verify. The four presentation vectors present-created-at-one-digit-hour, present-created-at-comma-fraction, present-created-at-offset, and present-created-at-lowercase-z fail the presentation check. Every other one is refused at parse. A timestamp token's TSTInfo is read by position, and its genTime is the fifth member, a universal GeneralizedTime, so another tag in that position fails the anchor check even when a valid GeneralizedTime follows it. genTime is read in the one form RFC 3161 gives it, YYYYMMDDhhmmss, then an optional period and fraction digits with no trailing zero, then Z, with the same field ranges, and a token whose genTime takes any other form fails the anchor check. The vectors whose names begin with anchor-gen-time- pin the form, the slot and the microsecond comparisons.

Producer and signatures

producer.public_key is the producer's raw 32-byte ed25519 public key, base64 standard encoding. producer.key_id is sha256: over those raw key bytes. The signing key is generated at product initialization and never leaves the install; the public key and its fingerprint belong on the operator's trust page so relying parties can pin them out of band.

A signature is computed over the canonical form of the bundle with signatures set to the empty array. sig is the base64 ed25519 signature. A bundle carries at least one signature whose key_id matches producer.key_id. Verifiers compare key_id against a pinned fingerprint when the caller provides one.

A pin is sha256: and 64 lowercase hex digits. A verifier refuses a pin its caller supplied in any other form, an empty one included, before it verifies anything: a caller who supplied a pin meant to pin, and an empty value read as no pin would verify a bundle from any key with the outcome of a matched one. This binds every entry point that can tell a supplied pin from an absent one, such as a command-line flag or a function argument. A library option whose empty value means no pin is the unpinned run by construction, and a malformed pin given there fails the bundle as a mismatch. A bundle that verifies with no pin reads the same VERIFIED as a pinned one, beside a line saying no signer was pinned and with producer_pinned false in the report. The qualification is that line, never a different word, so every shipped surface prints one verdict for one report.

SwitchTender's existing signed audit export uses hex-encoded keys; its LoomSeal emitter re-encodes the same key as base64. Same key, same trust, one envelope.

Signature entries carry an alg field so the format can adopt new algorithms without a new envelope. The chains and evidence digests rest on SHA-256 and HMAC-SHA256, which known quantum algorithms do not meaningfully weaken. The ed25519 signature is the component a large future quantum computer would break; a later format version adds a NIST-standardized post-quantum signature algorithm beside it. External anchoring already bounds that risk: a signature forged years from now cannot rewrite history whose heads were anchored outside the producer's control before such forgery was possible.

Chain profiles

A chain fixes claims in an append-only order. Products already have chains with different constructions, so LoomSeal names each construction as a profile and the verifier implements the profiles. A bundle declares one profile in chain.profile. Claims carry chain.seq, chain.prev, and chain.link. Claims in a bundle are sorted by ascending seq and must be contiguous; discontinuous history means separate bundles.

chain.head records the newest coordinates the producer attests for the whole chain, which may lead the claims a bundle carries: a bundle is a window into a longer history. When the head sequence equals the newest bundled claim, the verifier confirms the head link against that claim and reports the head as matched. When the head leads the claims, its link cannot be recomputed from the bundle and stays unverified; the report says so and does not treat that head as proof of anything beyond the window.

switchtender-audit-v1

The shipped SwitchTender construction. Each audit entry's link is SHA-256 over the compact JSON array, no insignificant whitespace, of six strings: its sequence as a decimal string, the time, actor, method, path, and previous link. Sequence starts at 1; the genesis previous link is the empty string. This profile is unkeyed: any verifier recomputes every link from the claim payloads alone. A bundle declaring chain.keyed true under it fails the chain check rather than being verified structurally, as switchtender-keyed and switchtender-keyed-altered pin. The time is the claim's at in UTC, RFC 3339 with nanosecond precision, trailing zeros in the fractional part trimmed and the fractional part and its dot omitted entirely when the fraction is zero, so an at of 2026-07-27T15:00:00Z serializes back to 2026-07-27T15:00:00Z. That round-trips the stored form exactly.

loomseal-chain-v1

The generic profile for new producers. The claim digest is sha256: over the canonical form of the claim object with its chain member removed. The link is HMAC-SHA256 (keyed) or SHA-256 (unkeyed) over the canonical form of:

{ "domain": "loomseal-chain-v1", "install_id": "...", "seq": 1, "prev": "", "claim": "sha256:..." }

Keyed chains state "keyed": true in the bundle's chain member.

The keyed form is by design not recomputable by third parties: the chain key is the secret that prevents forgery by a party who can write the underlying store. Relying parties verify keyed chains structurally, checking that each claim's prev equals the prior claim's link, and against anchors. The operator, holding the key, verifies fully.

Anchors

An anchor fixes a chain link in time, in a place the producer cannot rewrite alone. An anchor record carries the anchored seq and link, the anchor time, a ref locating the anchor, and optionally an embedded proof.

TypeRefOffline proof
rfc3161Timestamp authority name or URLYes, the embedded token over the link
gitCommit URL in a repository not oursBy reference, relying party fetches
httpsPublished head URL with retrieval dateBy reference
rekorTransparency log entryPlanned, not in v0.1

The verifier checks that each anchor's seq and link match a coordinate it verified: a claim in the bundle, or the head when the head tied to the newest claim. An anchor that matches only a declared head beyond the bundled claims is reported, but because that head link is unverified the anchor binds nothing the verifier confirmed and does not by itself earn the anchored level. An anchor that matches no verified coordinate fails the bundle. A verifier opens an embedded rfc3161 token and checks it offline: its message imprint is the SHA-256 of the anchored link's raw bytes, its signed attributes commit to its TSTInfo, its signature verifies against the timestamping certificate it carries, and its genTime falls inside that certificate's validity window and no more than five minutes before the entry it covers. A token that holds earns the anchored (proof verified) level, and one that does not fails the anchor check. An rfc3161 anchor without a proof reports as anchored by reference, and the relying party confirms the ref out of band. A proof on an anchor of another type that matched a coordinate the verifier confirmed is reported as one of a type the verifier cannot open, and an rfc3161 proof it opened never is, whether it held or failed. A proof on an anchor that matches only the unverified declared head is reported among the proofs on the declared head instead, whatever its type. Anchoring cadence bounds the window in which a compromised producer key could rewrite unanchored history; anchor often.

A token is read as DER, by position, and by no general ASN.1, X.509, or PKCS #7 parser, because each one's tolerances differ from the next. DER's identifier and length rules apply to every element the walk reaches, members no verifier interprets included: every element has a one-octet identifier and a definite length in its shortest form and lies wholly inside its container, nothing follows the ContentInfo, and an explicit tag or an encapsulating OCTET STRING holds exactly one element. That rules out an indefinite length, a long form length below 128 or with a leading zero octet, a length past its container, a stray octet inside any constructed element, and the high tag number form. An encoding wrapped in a primitive element, such as the TSTInfo, the RSA key, or an ECDSA signature, is read in full when a verifier reads it at all.

DER's value rules apply to the values a verifier reads. Such an INTEGER is non-empty and in its shortest form, such an OBJECT IDENTIFIER is non-empty with every subidentifier in its shortest form and no larger than 2^31-1, a certificate's critical flag is present only as TRUE, 0xFF, the RSA parameters are a NULL with no content, the key BIT STRING has no unused bits, and the signer certificate's validity times are each a UTCTime YYMMDDhhmmssZ or a GeneralizedTime YYYYMMDDhhmmssZ. In a member no verifier interprets these value rules are not checked, so a token still verifies when such a member holds a constructed string, an INTEGER, OBJECT IDENTIFIER, BOOLEAN, NULL, or time that DER would encode otherwise, nonzero BIT STRING padding bits, an encoded default value, a SET OF out of order, or a name string outside its character set. The TSTInfo serial number has at most 21 octets, the length DER gives a 160-bit value, because RFC 3161 has a verifier accept a serial of up to 160 bits and the ETSI EN 319 422 profile adds no rule of its own on the serial. The signer certificate is version 3, marked for timestamping, and holds an Ed25519 key, an ECDSA key on a NIST curve as an uncompressed point, or an RSA key of 1024 to 16384 bits holding only its modulus and exponent, and the signature algorithm must match that key. FORMAT.md states every rule, and the vectors whose names begin with anchor-der- and anchor-cert- pin them, the anchor-der-tag- family among them, so every member a verifier requires has a vector for its identifier. Each fails the anchor check except anchor-der-oid-arc-31-bits, a policy subidentifier of exactly 2^31-1, and anchor-der-serial-21-octets, a serial of 2^160-1, which verify.

A verifier names the signer from the signer certificate's subject alone, never from a parse of the whole certificate, and every verifier writes the same name. A subject made of relative distinguished names is written in the RFC 4514 form, last to first. The nine types RFC 4514 names are written by their short names, CN, L, ST, O, OU, C, STREET, DC, and UID, and a value of one of those nine is escaped text when it is a UTF8String, PrintableString, or IA5String that reads as text, and otherwise # and the hexadecimal of its encoding. Any other type is written in dotted form, and every value of a dotted type is # and the hexadecimal of its encoding, whatever string it holds. Control characters are written as hexadecimal escapes, so a name cannot act on a terminal, and so are the bidirectional embeddings, overrides, isolates, and marks and the line and paragraph separators, so a subject cannot use them to reorder or break the line naming its signer. A right-to-left letter is text and is written as it is, so it still reorders the digits and punctuation beside it when the line is displayed, as any right-to-left text does. Any other subject is # and the hexadecimal of its whole encoding. The vectors whose names begin with anchor-signer-name- pin the rule.

Population attestation: LoomSpan

A chain proves its entries were not edited. It does not prove entries were written. A producer that quietly declines to record an event emits a chain that is intact, fully verified, and misleading, and every signed-log design shares this hole. Auditors have a name for the gap: establishing that information produced by the entity is complete, not merely accurate. Completeness cannot be proven absolutely, because that would be a claim about events that left no trace. What a producer can do is commit, on a fixed cadence, to the exact population so far, which converts silent omission into an affirmative lie: a signed statement that was false when it was made.

This is the LoomSpan profile. Status: shipped in format 0.1; span claims are ordinary claims, so no envelope change was needed. A verifier without span support ignores the claim type and says so, which the registry rule already guarantees.

A span claim is an ordinary claim on the chain it attests, type loomseal.span/1, the first type in the spec-owned loomseal namespace. The population it counts is records: entries of this chain. Counting live resources or deployed components is a different job, deliberately not this one.

{
  "type": "loomseal.span/1",
  "at": "2026-08-02T10:01:00Z",
  "payload": { "stream": "chain", "cadence_s": 60, "beat": 1441, "count": 17 },
  "chain": { "seq": 18227, "prev": "...", "link": "..." }
}

The claim's at is the beat time. cadence_s is the interval, in whole seconds, the producer commits to, an integer from 1 to 31622400, the seconds in a 366-day year. A span claim declaring any other cadence fails the span check: a heartbeat slower than once a year attests nothing a reader can use, and the bound keeps every quantity the gap measurement forms inside a signed 64-bit count of microseconds, so no verifier can wrap or divide by a wrapped value. beat starts at 1 and increases by exactly 1 per span claim on the chain. count is the number of entries appended since the previous span claim. For beat 1, count covers every entry that precedes it, so a chain adopting the profile mid-life attests its entire prior population once, at adoption. cadence_s, beat, and count are each a JSON number with an integer value. A JSON true or false, a string, or any other type in their place fails the span check, and a verifier whose language counts a boolean as an integer refuses it all the same.

Because a span claim sits in the chain it attests, its own chain.prev is the head it commits to, and count is redundant with the sequence numbers: the entries between two span claims number exactly the difference of their seq values minus one. The verifier recomputes that difference, and a mismatch fails the bundle. The redundancy is the point. At every beat the producer signs a number it cannot later shrink without contradicting either the links, which the chain checks catch, or its own counts, which this profile catches.

Two failure shapes are deliberately distinct. A missing beat number is a deleted window, and it fails the bundle. Beat times further apart than the declared cadence are a gap: the collector went quiet, nothing was provably deleted, and the verifier reports the gap with its bounds and duration instead of failing. Coverage is always reported as measurement, never as a badge. Attested every 60s with a longest unattested window of 74s is worth more than a completeness stamp, and it is the only phrasing that survives a skeptical reader.

A gap is measured one way, so that every verifier reports the same one. Beat times are read to the microsecond, with any finer digits dropped, and the interval between consecutive beats is the difference of the two, compared against the cadence the earlier beat declares. The arithmetic is exact. A beat time carries a four-digit year, so every interval and every window count fits a signed 64-bit count of microseconds, and no verifier clamps, saturates, or wraps one. A beat is written when a timer fires, and a timer fires a little late as a matter of course, so every interval is allowed a scheduling slack of one hundredth of the cadence and never less than one second: an interval of at most the cadence plus the slack is on time, and one wider than that is a gap. A gap is reported as unattested window of <duration> between beat <n> (<time>) and beat <m> (<time>), with the duration as the interval rounded to the nearest whole second, halves rounding up, written as 150s, and each beat time in RFC 3339 UTC to the whole second, its fraction dropped rather than rounded. The longest gap is the widest such interval, written the same way. Coverage is reported as <beats>/<windows> windows attested, where the beats are the span claims the bundle carries and the windows add, for every gap, the interval divided by the cadence and rounded to the nearest whole number, halves rounding up, less one for the window the arriving beat attests. So at a 60s cadence a beat 61s after its predecessor is on time, one 62s after it is a gap of 62s that missed no whole window, and one 150s after it is a gap of 150s that missed two, read as 2/4 windows attested over two beats. A beat 62.5s after its predecessor at 15:01:00 is a gap of 63s, and the gap line names that beat 15:02:02Z. One 62.4999995s after it is read as 62.499999s and is a gap of 62s.

One limit carries the architecture. A chain that stops emitting beats and stops anchoring simply ends, and a bundle cannot distinguish ending from having nothing more to say. Silence is only detectable where an outside party expects the next beat and can see it missing. Publish beat heads on the declared cadence through the git or https anchor types, to a feed where a missing entry is visible to anyone. The feed, not the bundle, is where going dark becomes loud.

Twenty-two conformance vectors cover the profile: a valid spanned bundle, a false count, a missing beat, a cadence, a beat, and a count each written as a JSON boolean, a gap reported rather than failed, a mid-life adoption, a beat on the slack edge and one just past it at each of two cadences, a beat half a second late, a beat a tenth of a microsecond past the slack edge, a gap of two and a half cadences, gaps of 62.5s and 62.4999995s on either side of the rounding boundary, beats centuries apart at a one minute cadence and at the widest cadence allowed, a cadence one second wider than that, one too wide for a nanosecond duration, and one past the integer range. All three shipped verifiers, Go, Python, and the browser build, reach the same verdict on every one, and on each that verifies they agree down to the coverage line, the longest gap, and the wording of each gap.

Conformance levels

LevelNameMeaning
1SignedValid producer signature over the canonical bundle
2ChainedClaims linked in a declared profile, continuity verifies
3AnchoredAt least one verified anchor binds the chain outside the producer
4SpannedAnchored, plus span claims present and every span check verifies

Level 2 verification is full for unkeyed profiles (every link recomputed) and structural for keyed profiles (continuity of prev to link, with full verification reserved to the key holder). The verifier's report names which form it performed. Marketing language maps one to one: signed, chained, anchored, spanned. No other adjectives.

Verification

The verifier performs these steps in order and fails closed:

  1. Read the document as JSON, then its version, then the schema, in that order. A document outside JSON's grammar, one carrying NaN, Infinity, or a digit from another script included, is refused at parse whatever version it would declare. A loomseal string other than 0.1 is unsupported whatever else the document carries, because a later version may change its members, their types, and its numbers. A document with no loomseal string declares no version and is refused at parse. Every number is then held to the integer profile, every member to its exact name and type, and each value to its rule, where an unknown chain profile or signature alg is unsupported. FORMAT.md states the order in full, and the vectors whose names begin with version- or wrong-version pin it.
  2. Reconstruct the canonical form with signatures emptied and verify at least one signature against producer.public_key. If the caller pinned a fingerprint, require key_id match.
  3. If chain is present: require claims sorted, contiguous, and profile known. Recompute every link for unkeyed profiles; check continuity for keyed profiles.
  4. For each anchor: match its coordinates to the bundle, verify embedded proofs, report the anchor set with times and refs.
  5. If span claims are present: require beat contiguity, recompute every count from the sequence numbers, compare beat times against the declared cadence, and report coverage: beats present, gaps with bounds, longest gap, each measured and worded as the LoomSpan section states. A false count or a missing beat fails the bundle; a gap is reported, never hidden.
  6. For each evidence artifact supplied to the verifier: recompute its digest and compare. Evidence not supplied is reported as referenced, not checked, never as verified.
  7. Report the conformance level achieved and an overall verdict. Any failed check fails the bundle, and a failed bundle achieved no level: its report words the level as not verified, whatever the earlier steps held, and a bundle the verifier does not implement words it as unsupported. The level names what the verdict established, never the step a failed bundle reached, so a reader keying on it is not told that a bundle with a corrupt anchor proof is anchored.

Who can verify what:

RoleHoldsCan verify
OperatorDatabase, chain keyEverything, via the product's own verify
Relying partyBundle, verifier, no secretsSignature, digests, unkeyed links, continuity, anchors
PublicPublished heads onlyHead existence and consistency over time

Claim types

Claim types are namespaced product.kind/major. The payload of each type is owned by the emitting product and documented there; this registry fixes the names and required minimums.

TypeEmitted byStatusPayload minimum
switchtender.audit/1SwitchTenderv0.1actor, method, path
switchtender.run/1SwitchTenderdraftrun id, kind, hosts, approver
loomseal.span/1Any producerv0.1stream, cadence_s, beat, count

New types enter by change to this registry. Product namespaces belong to their products. The loomseal namespace is owned by this specification: its types are defined here, and any producer may emit them. A breaking payload change bumps the major suffix; verifiers ignore types they do not know and say so in the report.

A claim may carry a verdict: the deterministic policy judgment recorded at detection time, with the policy name, the policy definition digest, and the decision. In v0.1 the verifier attests the verdict fields and digests; recomputing verdicts from policy plus inputs is future work and out of scope here.

What a bundle proves, and what it does not

A verified level 3 bundle proves: the producer holding the signing key assembled these claims; the claims sit in an append-only order that has not been reordered or rewritten since the anchored moments; the evidence digests match any artifacts presented; the anchored history predates the anchor times.

A verified level 4 bundle adds: at every beat the producer committed to the exact entry population so far, so an entry removed after its beat contradicts either the links or the counts, and a deleted beat is itself visible. It still does not prove an event was recorded in the first place; a beat bounds when an omission had to begin, not whether one happened. And it says nothing about silence after the newest anchored beat, which only the published feed can show.

It does not prove: that the producer observed the world honestly at capture time (a chain fixes the record, not the honesty of the recorder); that a keyed chain is internally valid without the key (that verification belongs to the key holder); anything about entries created and rewritten between anchors by an attacker holding both the database and the chain key (cadence bounds this window). State these limits plainly everywhere the format is described. The credibility of the whole house rests on never claiming more than the verifier checks.

Media type and file names

Media type application/vnd.kordloom.loomseal+json. File name <subject>-<date>.loomseal.json. A bundle with sidecar evidence travels as a directory or archive; the bundle stays one file.

Compatibility

A bundle may be wrapped in a DSSE envelope with payload type application/vnd.kordloom.loomseal+json for tooling that expects DSSE. Mapping claims onto in-toto attestation predicates is possible later and deliberately not part of v0.1.