tallwright-core 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,93 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """tallwright_core — shared record schema, content-addressing, and Merkle primitives.
3
+
4
+ The dependency-light foundation every other Tallwright package builds on. No
5
+ networked or external-service code lives here (no TSA, transparency log, or storage).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .document import AmbiguousDocument, DocumentTooDeep, load_document
11
+ from .write import write_document
12
+ from .canonical import (
13
+ MAX_CANONICAL_DEPTH,
14
+ MAX_CANONICAL_NODES,
15
+ CanonicalizationError,
16
+ canonicalize,
17
+ )
18
+ from .hashing import (
19
+ content_address,
20
+ decode_content_address,
21
+ decode_multihash,
22
+ multihash,
23
+ MalformedSalt,
24
+ SALT_LEN,
25
+ address_from_digest,
26
+ opens_address,
27
+ sha256,
28
+ verify,
29
+ )
30
+ from .merkle import (
31
+ MerkleTree,
32
+ consistency_proof,
33
+ inclusion_proof,
34
+ leaf_hash,
35
+ merkle_root,
36
+ node_hash,
37
+ verify_consistency,
38
+ verify_inclusion,
39
+ )
40
+ from .schema import (
41
+ CANONICALIZATION_METHOD,
42
+ HASH_ALGORITHM,
43
+ SCHEMA_VERSION,
44
+ AnchorReceipt,
45
+ EventType,
46
+ GapManifest,
47
+ Record,
48
+ link,
49
+ verify_chain,
50
+ )
51
+
52
+ __all__ = [
53
+ # reading a document, and replacing one
54
+ "load_document",
55
+ "AmbiguousDocument",
56
+ "DocumentTooDeep",
57
+ "write_document",
58
+ # canonicalization
59
+ "canonicalize",
60
+ "CanonicalizationError",
61
+ "MAX_CANONICAL_DEPTH",
62
+ "MAX_CANONICAL_NODES",
63
+ # hashing
64
+ "sha256",
65
+ "multihash",
66
+ "decode_multihash",
67
+ "content_address",
68
+ "MalformedSalt",
69
+ "SALT_LEN",
70
+ "address_from_digest",
71
+ "opens_address",
72
+ "decode_content_address",
73
+ "verify",
74
+ # merkle
75
+ "leaf_hash",
76
+ "node_hash",
77
+ "merkle_root",
78
+ "inclusion_proof",
79
+ "consistency_proof",
80
+ "verify_inclusion",
81
+ "verify_consistency",
82
+ "MerkleTree",
83
+ # schema
84
+ "EventType",
85
+ "GapManifest",
86
+ "AnchorReceipt",
87
+ "Record",
88
+ "link",
89
+ "verify_chain",
90
+ "SCHEMA_VERSION",
91
+ "HASH_ALGORITHM",
92
+ "CANONICALIZATION_METHOD",
93
+ ]
@@ -0,0 +1,88 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Hardened RFC 8785 (JCS) canonicalization.
3
+
4
+ Wraps ``rfc8785.dumps`` with fail-closed guards so a hostile or malformed input becomes
5
+ a *typed, catchable* error instead of a crash, a silent drop, or non-deterministic bytes:
6
+
7
+ - **non-finite floats** (NaN / +-Infinity) are rejected — RFC 8785 has no representation
8
+ for them, and letting one through would make a hash irreproducible;
9
+ - **nesting depth is bounded** (``MAX_CANONICAL_DEPTH``) so a deeply-nested structure
10
+ cannot exhaust the interpreter stack (a cheap capture-path DoS);
11
+ - **total work is bounded** (``MAX_CANONICAL_NODES``) so a shallow but shared-reference
12
+ DAG cannot expand to exponentially many nodes and hang the serializer;
13
+ - any residual library-level failure (unserializable type, non-string key, recursion)
14
+ is re-raised as :class:`CanonicalizationError`.
15
+
16
+ For every input **within the depth / total-work bounds**, the output is byte-for-byte
17
+ identical to a raw ``rfc8785.dumps`` call — so record hashes are unchanged. Inputs that
18
+ exceed those bounds (or that ``rfc8785`` itself rejects) raise a TYPED error rather than hanging or
19
+ silently producing a non-reproducible value. What the capture layer then does depends on its mode:
20
+ under the SHIPPED DEFAULT (``fail_closed=False``) the error is counted and sealed as an auditable
21
+ rupture marker, and the agent keeps serving; with ``fail_closed=True`` it propagates and NOTHING is
22
+ sealed — no gap record, no rupture marker, and no unrecorded action either.
23
+ The bounds are set far above any real record or tool payload, so this only ever trips a
24
+ pathological/hostile input.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import math
30
+ from collections.abc import Mapping, Sequence
31
+ from typing import Any
32
+
33
+ import rfc8785
34
+
35
+ # JSON nesting deeper than this is rejected before serialization. Real records and tool
36
+ # payloads are shallow; this only trips a pathological/hostile input.
37
+ MAX_CANONICAL_DEPTH = 64
38
+ # Upper bound on the total number of values visited during the guard walk. Bounds total
39
+ # work so a shared-reference DAG (shallow but exponentially wide when traversed as a tree)
40
+ # is rejected quickly instead of hanging. JSON from ``model_dump`` is a tree (node count ~=
41
+ # payload size), so this only rejects a genuinely enormous or hostile-DAG payload — which
42
+ # then becomes an auditable gap, never an unbounded hang.
43
+ MAX_CANONICAL_NODES = 1_000_000
44
+
45
+
46
+ class CanonicalizationError(ValueError):
47
+ """An input cannot be deterministically canonicalized under RFC 8785 (JCS)."""
48
+
49
+
50
+ def canonicalize(obj: Any) -> bytes:
51
+ """Return the RFC 8785 (JCS) canonical bytes of ``obj``, or raise CanonicalizationError.
52
+
53
+ Guards run *before* serialization so depth/finiteness/total-work violations are reported
54
+ without ever entering the C serializer with a hostile input. For any already-canonicalizable
55
+ input the output is byte-identical to a raw ``rfc8785.dumps`` call.
56
+ """
57
+ remaining = MAX_CANONICAL_NODES
58
+
59
+ def guard(node: Any, depth: int) -> None:
60
+ nonlocal remaining
61
+ remaining -= 1
62
+ if remaining < 0:
63
+ raise CanonicalizationError(f"input exceeds MAX_CANONICAL_NODES={MAX_CANONICAL_NODES}")
64
+ if depth > MAX_CANONICAL_DEPTH:
65
+ raise CanonicalizationError(f"nesting exceeds MAX_CANONICAL_DEPTH={MAX_CANONICAL_DEPTH}")
66
+ if isinstance(node, bool):
67
+ return # bool before int/float; nothing to recurse into
68
+ if isinstance(node, float):
69
+ if not math.isfinite(node):
70
+ raise CanonicalizationError(f"non-finite float {node!r} has no RFC 8785 representation")
71
+ return
72
+ if isinstance(node, Mapping):
73
+ for value in node.values():
74
+ guard(value, depth + 1)
75
+ return
76
+ if isinstance(node, (str, bytes)):
77
+ return # sized but not a container to descend
78
+ if isinstance(node, Sequence):
79
+ for item in node:
80
+ guard(item, depth + 1)
81
+
82
+ guard(obj, 0)
83
+ try:
84
+ return rfc8785.dumps(obj)
85
+ except (ValueError, TypeError, RecursionError, OverflowError) as exc:
86
+ # rfc8785's own IntegerDomainError / etc. subclass ValueError; normalize them all
87
+ # (and any residual serializer failure) to one typed, catchable error.
88
+ raise CanonicalizationError(str(exc)) from exc
@@ -0,0 +1,105 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Reading a document whose meaning does not depend on who reads it.
3
+
4
+ JSON permits an object to carry the same key twice, and every parser resolves that its own way:
5
+ ``json.loads`` keeps the LAST, a person reading the file sees the FIRST, and another tool may see
6
+ either. A file with two readings is not a document — and an exhibit is a document before it is
7
+ anything else.
8
+
9
+ What that buys a producer, measured against this project's own golden vector before this module
10
+ existed: insert ``"action_type":"password_reset",`` in front of the real ``"action_type":"wire_transfer"``
11
+ and the parsed object is unchanged, so the canonicalization is unchanged, so **every signature still
12
+ verifies**. The court-facing tool prints its PASS lines for a file that reads as a different action
13
+ than the one it authenticates. No key is forged; the ambiguity is in the format.
14
+
15
+ So it is refused, at the door, wherever this project reads a document it will act on.
16
+
17
+ **Nor is a document read past a depth nobody writes.** ``json.loads`` recurses once per level of
18
+ nesting, and two hundred thousand ``[`` made it raise RecursionError — which is not a ValueError, so it
19
+ passed every reader's "unreadable" branch: the verifier printed a traceback and exited 1, the code for
20
+ evidence that was tampered with, and the same program in a browser lost its WebAssembly worker, whose
21
+ stack runs out before Python's recursion check does. The depth is therefore counted BEFORE anything
22
+ recurses, and a document past :data:`MAX_DOCUMENT_DEPTH` is refused as the ValueError it always should
23
+ have been.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import re
30
+ from typing import Any
31
+
32
+ from .canonical import MAX_CANONICAL_DEPTH
33
+
34
+ __all__ = ["AmbiguousDocument", "DocumentTooDeep", "MAX_DOCUMENT_DEPTH", "load_document"]
35
+
36
+ #: The deepest a document read here may nest, its own outermost object or array counting as one. An
37
+ #: exhibit nests 6 levels, a policy 4 and a disclosure 1; the deepest object this project canonicalises
38
+ #: nests MAX_CANONICAL_DEPTH + 1, and this leaves room for it inside an envelope as deep again. CPython
39
+ #: 3.12 raises RecursionError near 3,000 levels and a browser's WebAssembly stack gives out sooner, so
40
+ #: the bound sits far below both, and far above anything real.
41
+ MAX_DOCUMENT_DEPTH = 2 * MAX_CANONICAL_DEPTH
42
+
43
+ #: One JSON string — closed, or running to the end of a text that never closes it — or one bracket. The
44
+ #: possessive quantifiers consume a string's run once and never give it back, so no input makes the scan
45
+ #: restart inside a string it has passed: the scan is linear in the text.
46
+ _TOKENS = re.compile(r'"(?:[^"\\]++|\\.)*+(?:"|\\?\Z)|[\[\]{}]', re.DOTALL)
47
+
48
+
49
+ class AmbiguousDocument(ValueError):
50
+ """A document that reads one way to one parser and another way to the next."""
51
+
52
+
53
+ class DocumentTooDeep(ValueError):
54
+ """A document nested deeper than anything this project writes, refused before it is parsed."""
55
+
56
+
57
+ def _no_repeats(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
58
+ seen: dict[str, Any] = {}
59
+ for key, value in pairs:
60
+ if key in seen:
61
+ raise AmbiguousDocument(
62
+ f"the key {key!r} appears twice in one object. A reader of this file and a parser "
63
+ f"of it would not agree on what it says, so it is not read here."
64
+ )
65
+ seen[key] = value
66
+ return seen
67
+
68
+
69
+ def _deeper_than(text: str, bound: int) -> bool:
70
+ """Whether any point of ``text`` lies inside more than ``bound`` objects and arrays.
71
+
72
+ Brackets inside strings are not nesting, and an escaped quote does not end a string. Malformed text
73
+ is not this function's to judge: it counts what it finds, and the parser refuses the rest.
74
+ """
75
+ if text.count("[") + text.count("{") <= bound:
76
+ return False # a text cannot nest deeper than it has openings; nearly every document stops here
77
+ depth = 0
78
+ for token in _TOKENS.finditer(text):
79
+ first = text[token.start()]
80
+ if first in "[{":
81
+ depth += 1
82
+ if depth > bound:
83
+ return True
84
+ elif first in "]}":
85
+ depth -= 1
86
+ return False
87
+
88
+
89
+ def load_document(text: str | bytes) -> Any:
90
+ """``json.loads``, refusing any object that carries a key twice and any document nested deeper than
91
+ :data:`MAX_DOCUMENT_DEPTH`.
92
+
93
+ Raises :class:`AmbiguousDocument` for a repeated key, :class:`DocumentTooDeep` for the nesting, and
94
+ ``json.JSONDecodeError`` — a ``ValueError`` like both — for anything else malformed, so a caller that
95
+ already handles unreadable input keeps handling it. Bytes are decoded the way ``json.loads`` decodes
96
+ them.
97
+ """
98
+ source = text.decode(json.detect_encoding(text), "surrogatepass") if isinstance(text, bytes) else text
99
+ if _deeper_than(source, MAX_DOCUMENT_DEPTH):
100
+ raise DocumentTooDeep(
101
+ f"it is nested deeper than {MAX_DOCUMENT_DEPTH} levels. Nothing this project writes comes "
102
+ f"near that, and a parser following it would run out of stack before it ran out of "
103
+ f"document, so it is not read here."
104
+ )
105
+ return json.loads(source, object_pairs_hook=_no_repeats)
@@ -0,0 +1,207 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Content-addressing and self-describing multihash (SHA-256).
3
+
4
+ Zero external dependencies. SHA-256 is carried as a self-describing *multihash*
5
+ so a digest records *which* algorithm produced it — the algorithm-agility
6
+ property the seal spec needs for long-term verifiability. Only sha2-256 is supported in V1;
7
+ the structure leaves room for a future migration (e.g. to an SHA-3 / longer hash)
8
+ without breaking stored proofs.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import hmac
15
+
16
+ # multicodec code for sha2-256 is 0x12; its digest length is 32 bytes (0x20).
17
+ SHA2_256_CODE = 0x12
18
+ SHA2_256_LENGTH = 32
19
+ # multibase prefix for base16, lower-case (RFC 4648). Keeps content addresses
20
+ # trivially inspectable and verifiable.
21
+ _MULTIBASE_BASE16_LOWER = "f"
22
+
23
+
24
+ def sha256(data: bytes) -> bytes:
25
+ """Return the raw 32-byte SHA-256 digest of ``data``."""
26
+ return hashlib.sha256(data).digest()
27
+
28
+
29
+ def _uvarint_encode(value: int) -> bytes:
30
+ if value < 0:
31
+ raise ValueError("uvarint cannot encode a negative value")
32
+ out = bytearray()
33
+ while True:
34
+ byte = value & 0x7F
35
+ value >>= 7
36
+ if value:
37
+ out.append(byte | 0x80)
38
+ else:
39
+ out.append(byte)
40
+ return bytes(out)
41
+
42
+
43
+ def _uvarint_decode(buf: bytes, offset: int) -> tuple[int, int]:
44
+ result = 0
45
+ shift = 0
46
+ pos = offset
47
+ while True:
48
+ if pos >= len(buf):
49
+ raise ValueError("truncated uvarint")
50
+ byte = buf[pos]
51
+ pos += 1
52
+ result |= (byte & 0x7F) << shift
53
+ if not byte & 0x80:
54
+ return result, pos
55
+ shift += 7
56
+ if shift > 63:
57
+ raise ValueError("uvarint too long")
58
+
59
+
60
+ def multihash(data: bytes, *, algo: str = "sha2-256") -> bytes:
61
+ """Return the self-describing multihash of ``data``.
62
+
63
+ Layout: ``uvarint(code) || uvarint(length) || digest``.
64
+ """
65
+ if algo != "sha2-256":
66
+ raise ValueError(f"unsupported hash algorithm: {algo!r}")
67
+ digest = sha256(data)
68
+ return _uvarint_encode(SHA2_256_CODE) + _uvarint_encode(len(digest)) + digest
69
+
70
+
71
+ def decode_multihash(mh: bytes) -> tuple[int, int, bytes]:
72
+ """Return ``(code, length, digest)`` from a multihash, validating length."""
73
+ code, pos = _uvarint_decode(mh, 0)
74
+ length, pos = _uvarint_decode(mh, pos)
75
+ digest = mh[pos:]
76
+ if len(digest) != length:
77
+ raise ValueError("multihash length does not match its declared length")
78
+ return code, length, digest
79
+
80
+
81
+ def content_address(data: bytes) -> str:
82
+ """Return a multibase (base16-lower) content address for ``data``.
83
+
84
+ Used for ``payload_hash`` and for the record chain address. NOT for ``record_id``, which the
85
+ recorder mints as a UUID4 and the reference pipeline sets to a human label.
86
+ """
87
+ return _MULTIBASE_BASE16_LOWER + multihash(data).hex()
88
+
89
+
90
+ def address_from_digest(digest: bytes) -> str:
91
+ """The same content address as :func:`content_address`, from a digest already computed.
92
+
93
+ For data that arrives in pieces and must not be held whole. A streamed response body is the
94
+ case this exists for: the transport adapter folds each chunk into a ``hashlib`` object and keeps
95
+ only the prefix it is allowed to seal, so a multi-gigabyte download costs the recorder a
96
+ constant amount of memory instead of twice the body.
97
+
98
+ The knowledge of what an address LOOKS like stays in this module, which is the point. A caller
99
+ assembling the multibase prefix and the multicodec header itself would be a second place that
100
+ knows the format, and a second place is a place that one day answers differently.
101
+ """
102
+ if len(digest) != SHA2_256_LENGTH:
103
+ raise ValueError(
104
+ f"expected a {SHA2_256_LENGTH}-byte sha2-256 digest, got {len(digest)} bytes"
105
+ )
106
+ header = _uvarint_encode(SHA2_256_CODE) + _uvarint_encode(SHA2_256_LENGTH)
107
+ return _MULTIBASE_BASE16_LOWER + (header + digest).hex()
108
+
109
+
110
+ #: How long a per-record salt is, everywhere, without exception.
111
+ #:
112
+ #: It is not a tuning knob. ``SHA-256(salt ‖ plaintext)`` commits to the CONCATENATION and not to
113
+ #: the SPLIT, so if the length were free, every ``k`` would give a second valid reading:
114
+ #: ``(salt ‖ plaintext[:k], plaintext[k:])`` opens the very same address. An operator could then
115
+ #: disclose a TRUNCATED payload — pushing the amount, the account, the instruction into what they
116
+ #: call the salt — and a court's own verifier would confirm it opens. Because RFC 8785 sorts the
117
+ #: keys, the choppable prefix is deterministic and starts with the fields most worth withholding.
118
+ #:
119
+ #: A fixed length removes the second reading: there is exactly one way to cut 32 bytes off the front.
120
+ #: Found 2026-08-25 by adversarial review, demonstrated against a real record before it was fixed.
121
+ SALT_LEN = 32
122
+
123
+
124
+ class MalformedSalt(ValueError):
125
+ """A salt that is not exactly :data:`SALT_LEN` bytes. Not a mismatch — an unreadable disclosure.
126
+
127
+ Kept distinct because the two say completely different things to a court. "This disclosure does
128
+ not open the address" accuses the operator of giving a false account of their own record. "This
129
+ file is not in the format" says nothing about the record at all, and rendering the second as the
130
+ first is exactly the kind of overstatement this codebase refuses everywhere else.
131
+ """
132
+
133
+
134
+ def opens_address(address: str, salt: bytes, plaintext: bytes) -> bool:
135
+ """Does this salt and this plaintext produce that committed address?
136
+
137
+ The whole check a court performs when the archive holds no content — the operator discloses the
138
+ two inputs, anyone recomputes, and no state belonging to the recorder is involved at any point.
139
+ That is why it keeps working after the recorder is gone, and why it lives in the spine
140
+ rather than in the service.
141
+
142
+ The salt is why an address is not a search key. Without it, a short payload ("Termin abgesagt")
143
+ could be recovered from its hash by trying candidates; with it, the operator holds the one thing
144
+ that opens the commitment. Lose the salt and the record can never be opened again — the chain
145
+ still proves it existed unaltered at that position, and nothing proves what it said.
146
+
147
+ Raises :class:`MalformedSalt` if the salt is not exactly :data:`SALT_LEN` bytes. That check is
148
+ what makes the split unique and therefore what makes this a commitment to the PAIR rather than
149
+ to their concatenation — see the note on :data:`SALT_LEN`.
150
+ """
151
+ if len(salt) != SALT_LEN:
152
+ raise MalformedSalt(
153
+ f"a salt is exactly {SALT_LEN} bytes; this one is {len(salt)}. Without a fixed length "
154
+ "the same address opens under many different splits, so a truncated payload would "
155
+ "verify as the whole one."
156
+ )
157
+ return content_address(salt + plaintext) == address
158
+
159
+
160
+ def is_content_address(value: str) -> bool:
161
+ """Is this string an address this module could have produced?
162
+
163
+ Three places asked `value.startswith("f")` instead, so `'f'` was a well-formed
164
+ address, and so were `'frei'`, `'f' + 'z' * 68`, a truncated digest and an
165
+ upper-case one. An archive reporting OK over a record whose payload_hash commits
166
+ to nothing leaves a court with nothing to recompute against, which is the whole of
167
+ the disclosure story when the archive holds no content.
168
+
169
+ It lives here for the reason `address_from_digest` gives: a second place that knows
170
+ the format is a place that one day answers differently. Deliberately NOT a validator
171
+ on `Record.payload_hash` — the spine and verifier suites build link-only records with
172
+ stub hashes, and a schema validator would turn an existing bad log into a parse
173
+ failure instead of a named finding.
174
+ """
175
+ if not value.startswith(_MULTIBASE_BASE16_LOWER):
176
+ return False
177
+ body = value[1:]
178
+ # Upper-case hex is a DIFFERENT multibase ('F'), so a body that is not its own
179
+ # lower-case did not come from here even when it decodes.
180
+ if len(body) % 2 or body != body.lower():
181
+ return False
182
+ try:
183
+ code, length, digest = decode_multihash(bytes.fromhex(body))
184
+ except ValueError:
185
+ return False
186
+ return code == SHA2_256_CODE and length == SHA2_256_LENGTH and len(digest) == SHA2_256_LENGTH
187
+
188
+
189
+ def decode_content_address(addr: str) -> bytes:
190
+ """Inverse of :func:`content_address`; returns the raw multihash bytes."""
191
+ if not addr or addr[0] != _MULTIBASE_BASE16_LOWER:
192
+ raise ValueError("unsupported multibase prefix (expected base16 'f')")
193
+ return bytes.fromhex(addr[1:])
194
+
195
+
196
+ def verify(data: bytes, mh: bytes) -> bool:
197
+ """Return True iff ``mh`` is the multihash of ``data`` (constant-time)."""
198
+ try:
199
+ expected = multihash(data, algo="sha2-256")
200
+ except ValueError:
201
+ # FAIL CLOSED. Unreachable while sha2-256 is the only algorithm, but this module
202
+ # documents an algorithm migration as the point of the multihash envelope, and the
203
+ # migration is exactly when this branch starts to matter. `return True` here would
204
+ # make verify() answer True for ANY mh; the mutation lane found nothing pinning the
205
+ # direction, and `# pragma: no cover` meant coverage never would.
206
+ return False
207
+ return hmac.compare_digest(expected, mh)
@@ -0,0 +1,186 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """RFC 6962 Merkle tree: root, inclusion proofs, consistency proofs, verifiers.
3
+
4
+ Hashing is domain-separated exactly per RFC 6962 (leaf = ``SHA-256(0x00 || entry)``,
5
+ interior = ``SHA-256(0x01 || left || right)``) so proofs are portable and
6
+ interoperable with Certificate Transparency / Trillian-Tessera tooling — which is
7
+ what makes an export verifiable years later with no live service.
8
+
9
+ The prover (:func:`inclusion_proof`, :func:`consistency_proof`) follows the
10
+ recursive RFC 6962 definitions; the verifiers follow the iterative RFC 9162
11
+ algorithms. They are deliberately separate code paths so their agreement is
12
+ strong evidence of correctness.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from .hashing import sha256
18
+
19
+ LEAF_PREFIX = b"\x00"
20
+ NODE_PREFIX = b"\x01"
21
+
22
+
23
+ def leaf_hash(entry: bytes) -> bytes:
24
+ """RFC 6962 leaf hash of a raw entry."""
25
+ return sha256(LEAF_PREFIX + entry)
26
+
27
+
28
+ def node_hash(left: bytes, right: bytes) -> bytes:
29
+ """RFC 6962 interior node hash of two child hashes."""
30
+ return sha256(NODE_PREFIX + left + right)
31
+
32
+
33
+ def _largest_power_of_two_less_than(n: int) -> int:
34
+ # Largest k that is a power of two with k < n (requires n >= 2).
35
+ k = 1
36
+ while (k << 1) < n:
37
+ k <<= 1
38
+ return k
39
+
40
+
41
+ def merkle_root(entries: list[bytes]) -> bytes:
42
+ """Return the RFC 6962 Merkle Tree Hash (MTH) over the ordered entries.
43
+
44
+ The hash of an empty list is the hash of the empty string.
45
+ """
46
+ if not entries:
47
+ return sha256(b"")
48
+ if len(entries) == 1:
49
+ return leaf_hash(entries[0])
50
+ k = _largest_power_of_two_less_than(len(entries))
51
+ return node_hash(merkle_root(entries[:k]), merkle_root(entries[k:]))
52
+
53
+
54
+ def inclusion_proof(entries: list[bytes], index: int) -> list[bytes]:
55
+ """Return the audit PATH for the entry at ``index`` (RFC 6962)."""
56
+ n = len(entries)
57
+ if not 0 <= index < n:
58
+ raise IndexError("leaf index out of range")
59
+ if n == 1:
60
+ return []
61
+ k = _largest_power_of_two_less_than(n)
62
+ if index < k:
63
+ return inclusion_proof(entries[:k], index) + [merkle_root(entries[k:])]
64
+ return inclusion_proof(entries[k:], index - k) + [merkle_root(entries[:k])]
65
+
66
+
67
+ def _subproof(m: int, entries: list[bytes], at_root: bool) -> list[bytes]:
68
+ n = len(entries)
69
+ if m == n:
70
+ return [] if at_root else [merkle_root(entries)]
71
+ k = _largest_power_of_two_less_than(n)
72
+ if m <= k:
73
+ return _subproof(m, entries[:k], at_root) + [merkle_root(entries[k:])]
74
+ return _subproof(m - k, entries[k:], False) + [merkle_root(entries[:k])]
75
+
76
+
77
+ def consistency_proof(entries: list[bytes], first_size: int) -> list[bytes]:
78
+ """Return the consistency PROOF between the tree of ``first_size`` and the full tree."""
79
+ n = len(entries)
80
+ if not 0 <= first_size <= n:
81
+ raise ValueError("first_size out of range")
82
+ if first_size == 0 or first_size == n:
83
+ return []
84
+ return _subproof(first_size, entries, True)
85
+
86
+
87
+ def verify_inclusion(
88
+ entry: bytes, index: int, tree_size: int, root: bytes, proof: list[bytes]
89
+ ) -> bool:
90
+ """Verify an inclusion proof for a raw ``entry`` (RFC 9162 algorithm)."""
91
+ # BOTH bounds, and a positive tree_size. A NEGATIVE index must be rejected: Python's saturating
92
+ # >> and & on negatives pin `fn` to a fixed parity, so every leaf would otherwise have an infinite
93
+ # family of negative aliases that verify against the same signed root — a forged, producer-
94
+ # controlled leaf_index reading as VALID. (The prover, inclusion_proof, always checked both
95
+ # bounds; only the verifier did not. Found in review.)
96
+ if not 0 <= index < tree_size:
97
+ return False
98
+ fn, sn = index, tree_size - 1
99
+ r = leaf_hash(entry)
100
+ for p in proof:
101
+ if sn == 0:
102
+ return False
103
+ if fn & 1 or fn == sn:
104
+ r = node_hash(p, r)
105
+ if not fn & 1:
106
+ while fn and not fn & 1:
107
+ fn >>= 1
108
+ sn >>= 1
109
+ else:
110
+ r = node_hash(r, p)
111
+ fn >>= 1
112
+ sn >>= 1
113
+ return sn == 0 and r == root
114
+
115
+
116
+ def verify_consistency(
117
+ first_size: int,
118
+ second_size: int,
119
+ first_root: bytes,
120
+ second_root: bytes,
121
+ proof: list[bytes],
122
+ ) -> bool:
123
+ """Verify a consistency proof between two tree sizes (RFC 9162 algorithm)."""
124
+ # Reject negative sizes for the same reason as verify_inclusion: a negative operand would drive
125
+ # the shift/parity loop below into meaningless state rather than failing closed.
126
+ if first_size < 0 or second_size < 0 or first_size > second_size:
127
+ return False
128
+ if first_size == second_size:
129
+ return len(proof) == 0 and first_root == second_root
130
+ if first_size == 0:
131
+ return len(proof) == 0
132
+ fn, sn = first_size - 1, second_size - 1
133
+ while fn & 1:
134
+ fn >>= 1
135
+ sn >>= 1
136
+ it = iter(proof)
137
+ if fn == 0:
138
+ fr = sr = first_root
139
+ else:
140
+ try:
141
+ fr = sr = next(it)
142
+ except StopIteration:
143
+ return False
144
+ for c in it:
145
+ if sn == 0:
146
+ return False
147
+ if fn & 1 or fn == sn:
148
+ fr = node_hash(c, fr)
149
+ sr = node_hash(c, sr)
150
+ if not fn & 1:
151
+ while fn and not fn & 1:
152
+ fn >>= 1
153
+ sn >>= 1
154
+ else:
155
+ sr = node_hash(sr, c)
156
+ fn >>= 1
157
+ sn >>= 1
158
+ return sn == 0 and fr == first_root and sr == second_root
159
+
160
+
161
+ class MerkleTree:
162
+ """A small append-only RFC 6962 tree over in-memory entries."""
163
+
164
+ def __init__(self, entries: list[bytes] | None = None) -> None:
165
+ self._entries: list[bytes] = list(entries) if entries else []
166
+
167
+ def add(self, entry: bytes) -> int:
168
+ """Append a raw entry; return the new tree size."""
169
+ self._entries.append(entry)
170
+ return len(self._entries)
171
+
172
+ @property
173
+ def size(self) -> int:
174
+ return len(self._entries)
175
+
176
+ def entry(self, index: int) -> bytes:
177
+ return self._entries[index]
178
+
179
+ def root(self) -> bytes:
180
+ return merkle_root(self._entries)
181
+
182
+ def inclusion_proof(self, index: int) -> list[bytes]:
183
+ return inclusion_proof(self._entries, index)
184
+
185
+ def consistency_proof(self, first_size: int) -> list[bytes]:
186
+ return consistency_proof(self._entries, first_size)
File without changes
@@ -0,0 +1,176 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The court-defensible record envelope, canonicalization, and hash-chain.
3
+
4
+ Realizes the 14-field record schema. Design rule:
5
+ the *record* holds hashes + metadata; the full payload (the context value, or the
6
+ action parameters) is sealed **off-record** and referenced by ``payload_hash``.
7
+
8
+ Canonicalization is RFC 8785 (JCS) so the record hash is reproducible by a court
9
+ expert years later. ``anchor_receipt`` and ``certification_pointer`` are attached
10
+ *after* sealing (anchoring is batched; certification is added later), so they are
11
+ EXCLUDED from the hashed and hash-chained content — otherwise a record could not
12
+ be anchored after its hash was computed.
13
+
14
+ Framing discipline (non-negotiable): we record the context received and the action
15
+ triggered — never the agent's reasoning or intent. Integrity is from the moment of
16
+ capture, not the truth of the inputs.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from enum import Enum
22
+ from typing import Any
23
+
24
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
25
+
26
+ from .canonical import canonicalize
27
+ from .hashing import content_address, multihash
28
+
29
+ SCHEMA_VERSION = "0.1.0"
30
+ HASH_ALGORITHM = "sha2-256"
31
+ CANONICALIZATION_METHOD = "RFC8785-JCS"
32
+
33
+ # Attached post-sealing -> never part of the record hash / chain.
34
+ _UNHASHED_FIELDS = ("anchor_receipt", "certification_pointer")
35
+
36
+
37
+ class EventType(str, Enum):
38
+ """We seal what the agent received and what it triggered — nothing else."""
39
+
40
+ CONTEXT_RECEIVED = "CONTEXT_RECEIVED"
41
+ ACTION_TRIGGERED = "ACTION_TRIGGERED"
42
+
43
+
44
+ #: How a capture gap announces itself in ``GapManifest.notes``.
45
+ #:
46
+ #: Defined HERE because two packages have to agree on it and neither owns the other: the capture
47
+ #: middleware writes it when a capture is dropped, and the alarm reads it to tell the operator.
48
+ #: Two spellings of one marker is a gap that is recorded and never reported — which is the same as
49
+ #: not recording it, on the day it matters.
50
+ RUPTURE_NOTE_PREFIX = "rupture: "
51
+
52
+
53
+ class GapManifest(BaseModel):
54
+ """Declares which capture tiers were active, scoping any completeness claim.
55
+
56
+ Every tier defaults to FALSE, including the strongest one. A record built without saying how it
57
+ was captured must not claim we watched it happen — and until 2026-08-25 it did, because
58
+ ``tier1_runtime_hooks`` defaulted to True and ``Record.gap_manifest`` has a default factory.
59
+ Both archive paths of the product's closed service build records without a manifest, so every
60
+ archived record asserted boundary capture; on the hashes-only path, where the operator sends an
61
+ address and we see nothing by construction, that assertion was flatly false.
62
+
63
+ The adapter that genuinely installs runtime hooks says so explicitly and still does. Everything
64
+ else now under-claims, which is the only direction this may ever fail in.
65
+ """
66
+
67
+ model_config = ConfigDict(extra="forbid")
68
+
69
+ tier1_runtime_hooks: bool = False
70
+ tier2_transport_bytes: bool = False
71
+ tier3_mcp_proxy: bool = False
72
+ notes: str | None = None
73
+
74
+
75
+ class AnchorReceipt(BaseModel):
76
+ """External-anchor evidence (the moat field); attached after batched anchoring."""
77
+
78
+ model_config = ConfigDict(extra="forbid")
79
+
80
+ merkle_root: str
81
+ tree_size: int
82
+ anchored_at: str
83
+ timestamp_ref: str | None = None # always set; honest about the token
84
+ timestamp_algorithm: str | None = None # e.g. "local-ed25519-ts" (non-qualified) vs "rfc3161-cms"
85
+ qualified_timestamp_ref: str | None = None # ONLY set for a genuinely qualified token
86
+ witness_checkpoint_ref: str | None = None
87
+ public_anchor_ref: str | None = None
88
+
89
+
90
+ class Record(BaseModel):
91
+ """A single sealed context/action record.
92
+
93
+ The field numbers in comments number the record's fourteen fields.
94
+ """
95
+
96
+ model_config = ConfigDict(extra="forbid")
97
+
98
+ # 13 — re-verifiability + reproducible hashing
99
+ schema_version: str = SCHEMA_VERSION
100
+ hash_algorithm: str = HASH_ALGORITHM
101
+ canonicalization_method: str = CANONICALIZATION_METHOD
102
+
103
+ # 1 record id, 2 chain link, 3 content-address of the sealed payload
104
+ record_id: str
105
+ prev_record_hash: str | None = None
106
+ payload_hash: str
107
+
108
+ # 4 trusted time (RFC 3339 string; upgrade to a qualified TS where weight matters)
109
+ timestamp: str
110
+
111
+ # 5 runtime + version, 6 accountable operator
112
+ agent_system_id: str
113
+ agent_version: str
114
+ deployer_operator_id: str
115
+
116
+ # 7 event type; 8/9 the payload itself is off-record (payload_hash), action_type
117
+ # is the small inline metadata for ACTION_TRIGGERED
118
+ event_type: EventType
119
+ action_type: str | None = None
120
+
121
+ # 10 accountable authority
122
+ actor_authority: str | None = None
123
+
124
+ # 11 retention + legal hold
125
+ retention_class: str
126
+ legal_hold: bool = False
127
+
128
+ # capture-completeness scope
129
+ gap_manifest: GapManifest = Field(default_factory=GapManifest)
130
+
131
+ # 12 anchor (post-seal) + 14 certification (post-seal) -> EXCLUDED from the hash
132
+ anchor_receipt: AnchorReceipt | None = None
133
+ certification_pointer: str | None = None
134
+
135
+ @model_validator(mode="after")
136
+ def _check_event_fields(self) -> Record:
137
+ if self.event_type is EventType.ACTION_TRIGGERED:
138
+ if not self.action_type:
139
+ raise ValueError("ACTION_TRIGGERED requires action_type")
140
+ elif self.action_type is not None:
141
+ raise ValueError("action_type must be unset for CONTEXT_RECEIVED")
142
+ return self
143
+
144
+ def hashable_dict(self) -> dict[str, Any]:
145
+ """The JSON-able record content that the hash/chain cover (post-seal fields removed)."""
146
+ data: dict[str, Any] = self.model_dump(mode="json")
147
+ for field in _UNHASHED_FIELDS:
148
+ data.pop(field, None)
149
+ return data
150
+
151
+ def canonical_bytes(self) -> bytes:
152
+ """Deterministic RFC 8785 (JCS) serialization of the hashable content."""
153
+ return canonicalize(self.hashable_dict())
154
+
155
+ def record_hash(self) -> bytes:
156
+ """The raw multihash over the canonical bytes."""
157
+ return multihash(self.canonical_bytes())
158
+
159
+ def record_hash_address(self) -> str:
160
+ """The content address (multibase string) of this record's hash."""
161
+ return content_address(self.canonical_bytes())
162
+
163
+
164
+ def link(prev: Record, **fields: Any) -> Record:
165
+ """Build the next record, chaining ``prev_record_hash`` to ``prev``."""
166
+ return Record(prev_record_hash=prev.record_hash_address(), **fields)
167
+
168
+
169
+ def verify_chain(records: list[Record]) -> bool:
170
+ """Return True iff each record's ``prev_record_hash`` matches the prior record."""
171
+ prev_addr: str | None = None
172
+ for rec in records:
173
+ if rec.prev_record_hash != prev_addr:
174
+ return False
175
+ prev_addr = rec.record_hash_address()
176
+ return True
@@ -0,0 +1,36 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Replacing a document's contents in one step, or not at all.
3
+
4
+ The mirror of :mod:`tallwright_core.document`: that one refuses to READ a file that says two
5
+ things, this one refuses to LEAVE a file that is half of one.
6
+
7
+ ``Path.write_text`` opens with ``"w"``: it truncates first and writes second. A crash or a full
8
+ disk between those leaves a file that is neither the old contents nor the new " and the files this
9
+ matters for are the ones that hold everything. `fleet.json` carries every tenant an operator has,
10
+ every API-key hash and the witness identity they all anchor to; the operator config carries the
11
+ checkpoint key's public half and the origin. Losing one of those to a torn write costs a customer
12
+ list, not a cache.
13
+
14
+ **This does not claim durability.** Without an ``fsync`` a crash can lose the write entirely. What
15
+ it cannot do any more is lose it halfway, and a lost write leaves the OLD file " which is a thing
16
+ somebody can still read " where a torn one leaves nothing.
17
+
18
+ The scratch lives in the SAME directory, because ``os.replace`` is only atomic within one
19
+ filesystem. Where the target directory is sealed append-only, the scratch has to live outside it
20
+ instead and the rename goes in " that case is not this one and
21
+ ``Archive._stage_anchor_file`` handles it separately.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ from pathlib import Path
28
+
29
+ __all__ = ["write_document"]
30
+
31
+
32
+ def write_document(path: Path, text: str) -> None:
33
+ """Write ``text`` to ``path`` so a reader sees either the old contents or all of the new."""
34
+ scratch = path.with_name(f".{path.name}.part")
35
+ scratch.write_text(text, encoding="utf-8")
36
+ os.replace(scratch, path)
@@ -0,0 +1,42 @@
1
+ Metadata-Version: 2.5
2
+ Name: tallwright-core
3
+ Version: 0.1.0
4
+ Summary: Tallwright - shared record schema, content-addressing, and RFC 6962 Merkle primitives
5
+ Author: Tallwright
6
+ License-Expression: Apache-2.0
7
+ License-File: LICENSE
8
+ License-File: NOTICE
9
+ Keywords: ai-agents,audit,compliance,forensics,merkle,tamper-evident
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Legal Industry
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: pydantic<3,>=2
19
+ Requires-Dist: rfc8785<1,>=0.1
20
+ Description-Content-Type: text/markdown
21
+
22
+ # tallwright-core
23
+
24
+ The dependency-light foundation every other Tallwright package builds on. No
25
+ networked or external-service code lives here.
26
+
27
+ - **`hashing.py`** — SHA-256 self-describing multihash + multibase content-addressing
28
+ - **`merkle.py`** — RFC 6962 Merkle tree: root, inclusion + consistency proofs, and
29
+ independent (RFC 9162) verifiers
30
+ - **`schema.py`** — the 14-field record envelope,
31
+ RFC 8785 (JCS) canonicalization, record hash, and hash-chain (`link` / `verify_chain`).
32
+ Post-seal fields (`anchor_receipt`, `certification_pointer`) are excluded from the hash.
33
+
34
+ Status: **implemented + tested** (the first approved production code). Anchoring, signing,
35
+ storage, and capture live in later packages.
36
+
37
+ ## License
38
+
39
+ Copyright 2026 David Jöbstl. Licensed under the Apache License, Version 2.0: the package carries the license
40
+ text and the NOTICE. The exhibit
41
+ format's specification and its test vectors are under the same license; the rest of Tallwright is
42
+ proprietary.
@@ -0,0 +1,13 @@
1
+ tallwright_core/__init__.py,sha256=veR1oRr5cgnXJZZX4IV7ZyhLQeG4BzG3m-nNMi64J8Y,2002
2
+ tallwright_core/canonical.py,sha256=Wp_A24lWyaPnU-de9rhmS6dG1p_HE64emAD5FOLDE8E,4374
3
+ tallwright_core/document.py,sha256=6gUItl8iaQr-OXGr3WS7gcdcnAz6jKov89-KMzOVUhg,5197
4
+ tallwright_core/hashing.py,sha256=1F6SX31sCNCaEV23UpN6Dv3Z8uYdihdSX9qZ-peWDcU,9296
5
+ tallwright_core/merkle.py,sha256=xSUCJ2kMkRCF2G2irWxspFalh-NpsjPE9x2kWeIeses,6328
6
+ tallwright_core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ tallwright_core/schema.py,sha256=WT5f7oMDpZMjpNOdZY34BJaWk_NdND2tKjnbeNlNn2Q,6788
8
+ tallwright_core/write.py,sha256=DVD0JaWyvTez8NFBPt2LqFM4MpUMjXJmPdNxbkZvbz4,1710
9
+ tallwright_core-0.1.0.dist-info/METADATA,sha256=f8_2l70RJv-ntWZVwaiZzNBArZZHZ_Tjl_78jZHB7FM,1749
10
+ tallwright_core-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
11
+ tallwright_core-0.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
12
+ tallwright_core-0.1.0.dist-info/licenses/NOTICE,sha256=V9DbIbqPvS6ooaxdVebPZgfjXOgZcZcz7hLYGUeRMc8,262
13
+ tallwright_core-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
@@ -0,0 +1,6 @@
1
+ Tallwright public verifier
2
+ Copyright 2026 David Jöbstl
3
+
4
+ Licensed under the Apache License, Version 2.0: tallwright-core, tallwright-verifier, the
5
+ specification of the exhibit format and its test vectors. The license text is in the LICENSE
6
+ file beside this one.