Trusty: provenance library workspace (initial import)

This commit is contained in:
2026-09-09 18:31:00 -04:00
commit 693f3bef1e
73 changed files with 26295 additions and 0 deletions

View File

@@ -0,0 +1,196 @@
# Device-Signer Provisioning
Pattern for enrolling, using, rotating, and revoking Ed25519 signing
keys on devices (IoT sensors, edge nodes, agents) that append data to a
trusty chain.
---
## Problem
Many trusty deployments have more than one source of writes:
- A carbon-credit SaaS ingesting readings from hundreds of IoT sensors.
- An AI-agent audit log receiving tool-call records from many running
agents.
- A monitoring/logging API collecting events from thousands of customer
services.
Each source needs a persistent cryptographic identity. The chain needs
to record those signatures in a way external verifiers can check. The
operator needs a way to recognize an enrolled device, retire a
compromised one, or rotate a scheduled key.
Trusty keeps chain verification self-contained (signature + embedded
public key per row). Trust *policy* — which devices are still trusted
today — lives in the operator's application layer, not the chain.
---
## Core Idea
Trusty's v2 payload embeds each row's Ed25519 public key **inline** in
the signature object (see `docs/payload-v2.md` §4). This means:
- A proof bundle is **cryptographically self-verifying**: a consumer
with the payload bytes can check `verify(pk, payload, sig)` without
any registry lookup.
- Whether a particular pk *should have been trusted* at append time is a
separate question. That question is answered by an application-level
**signer registry**.
Two layers, cleanly separated:
```
+------------------------------------+
| Chain layer (trusty): |
| raw_payload, row_hash, |
| anchor_hash, signatures[pk,sig] |
| -> verifies math, not policy |
+------------------------------------+
| App layer (you): |
| signer_registry table |
| device_id, pk, enrolled_at, |
| revoked_at, metadata |
| -> verifies policy (was this |
| device trusted at time T?) |
+------------------------------------+
```
---
## Lifecycle
### 1. Enroll
On the device:
```rust
use trusty_core::{Ed25519Signer, Signer};
let signer = Ed25519Signer::generate(); // OS RNG
let secret = signer.secret_bytes(); // persist to
// device secure
// storage
let pk_hex = hex::encode(signer.public_key_bytes()); // send to
// operator
```
On the operator side (app), insert into a registry:
```sql
INSERT INTO signer_registry (device_id, public_key, enrolled_at)
VALUES (?, ?, ?);
```
The secret never leaves the device. Only the public key is transmitted.
### 2. Sign and append
```rust
let signer = Ed25519Signer::from_secret_bytes(&secret)?;
trusty.append_signed("device_42", &data, "sensor://42", &signer)?;
```
The resulting row carries the signature with the pk embedded inline.
### 3. Rotate
When a device's key reaches its scheduled lifetime (or for routine
hygiene), generate a new keypair on the device and register a new entry
(or update the existing one with a new pk + `rotated_at` timestamp).
**Old rows remain fully verifiable** — the old pk lives inline in those
rows. Only new rows use the new key.
### 4. Revoke
If a device is decommissioned, lost, or suspected of compromise:
```sql
UPDATE signer_registry SET revoked_at = ? WHERE device_id = ?;
```
Revocation is **not a chain event**. Revoked rows still carry valid
signatures (the math is unchanged). The app decides to distrust them:
- A proof-bundle consumer joins the bundle's rows against the registry
and flags any row where the pk's `revoked_at` precedes the row's
`detected_at`.
- An auditor treats revoked-key rows as quarantined, not missing.
---
## Compromise Scenarios
Per `docs/threat-model.md` §6 (per-key compromise), trusty's surface
already addresses the crypto side. The registry pattern closes the
policy side.
| Scenario | Chain behavior | Registry response |
|--------------------------------------- |---------------------------------|---------------------------------------|
| Device secret leaked; attacker signs | Row sig verifies mathematically | Revoke pk; downstream flags the row |
| Old key rotated out | Old-key rows still verify | Mark `rotated_at`; retain the entry |
| Device destroyed | Future writes just stop | Mark `revoked_at` with reason |
| Clone/replay across devices | Chain sequence gate detects it | Registry has no further role |
---
## Registry Schema (Reference, Not Mandatory)
Trusty does **not** prescribe a registry schema. Apps vary: a carbon-
credit platform cares about `device_type` and `location`; a monitoring
SaaS cares about `customer_id` and `plan`; an agent log cares about
`agent_version` and `operator_account`.
A minimal schema:
```sql
CREATE TABLE signer_registry (
device_id TEXT PRIMARY KEY,
public_key TEXT NOT NULL, -- hex-encoded 32 bytes
enrolled_at TEXT NOT NULL, -- ISO-8601
rotated_at TEXT, -- NULL until rotated
revoked_at TEXT, -- NULL until revoked
note TEXT
);
CREATE INDEX idx_sr_pk ON signer_registry(public_key);
```
The pk index exists so a verifier can look up registry status given a
pk extracted from a chain row.
---
## Worked Example
See `crates/trusty-sqlite/examples/device_signer.rs` for a runnable
demo that walks enroll → sign → rotate → revoke end to end on the same
database file trusty uses, using a second `rusqlite::Connection` for
the registry table.
Run:
```
cargo run -p trusty-sqlite --example device_signer
```
---
## What Trusty Does NOT Do (and Why)
- **No built-in registry.** Schema needs vary per product; a prescribed
schema would either over-serve (unused fields) or under-serve (missing
fields). Apps roll their own table.
- **No revocation as chain event.** Chain rows are immutable by design.
Introducing a "this pk is revoked" row would conflate cryptographic
truth (the sig is valid) with operational policy (we no longer trust
it). Those belong in different places.
- **No rotation ceremony.** Each v2 row carries its own pk inline;
switching to a new signer at append time is a one-line change on the
device. No chain-side mechanism needed.
The `Signer` trait accepts any backing implementation (HSM, TPM, cloud
KMS, file-backed). `Ed25519Signer` is the default software impl;
production deployments commonly substitute a hardware-backed signer.

315
docs/payload-v2.md Normal file
View File

@@ -0,0 +1,315 @@
# Trusty payload format -- v1 + v2
Canonical byte-level specification of what goes into `raw_payload`, what
`row_hash` commits to, and how v2 signatures are produced and verified.
This document is consensus-critical. v1 byte-shape is locked by the
invariant tests in `trusty-core` (see tag `s5_invariant_v1_byte_shape_preserved`
and the `v1_*` tests in `payload.rs` / `chain.rs`). v2 is purely additive
and must preserve v1 bytes exactly when `payload_version=1`. The original
Python port-source is archival only and no longer gates changes here.
Source of truth in-tree:
- `crates/trusty-core/src/payload.rs` -- canonical serializer, constants.
- `crates/trusty-core/src/chain.rs` -- `compute_record`, `compute_record_signed`,
`verify_links`, `verify_row_signatures`.
- `crates/trusty-core/src/hash.rs` -- `row_hash`, `anchor_hash`.
- `crates/trusty-core/src/crypto.rs` -- `Signer`, `Verifier`, Ed25519 impls.
---
## 1. Canonical form
Every `raw_payload` is a JSON object. Its canonical byte form is:
1. **Keys sorted alphabetically**, recursively (every nested object gets the
same treatment). Implemented via `BTreeMap` at the top level and a
recursive `canonicalize` on all nested `serde_json::Value::Object` nodes.
2. **No whitespace** -- `serde_json::to_string` (not `to_string_pretty`).
3. **UTF-8**. No BOM.
4. **Array order preserved**. Only object keys are sorted; arrays keep caller
order (this matters for `signatures`, which is an array -- but each entry
inside is itself an object and gets its keys sorted).
### 1.1 Field list (alphabetical -- the on-wire order)
| Field | Type | Notes |
|---------------------|------------|-----------------------------------------------------------------------|
| `chain_id` | string | `chain_<entity_id>_<6-digit-generation>` (e.g. `chain_acme_000001`). |
| `data` | any JSON | Caller-provided. Canonicalized recursively. Capped at 65,536 bytes after canonicalization. |
| `detected_at` | string | ISO-8601 UTC. Time trusty observed the event. |
| `entity_id` | string | Opaque caller identifier. |
| `extraction_method` | string | Caller tag for the provenance source (e.g. `trusty`, `scraper-v3`). |
| `hash_alg` | string | Always `"siphash24+sha256"` today. Verifiers read this before recomputing `row_hash`. |
| `payload_version` | number | `1` (v1) or `2` (v2). |
| `prev_hash` | string | Previous row's `row_hash` (16 hex chars), or the literal `"genesis"` on a segment's first row. |
| `received_at` | string | ISO-8601 UTC with sub-second precision. Distinct from `detected_at`; never collapse. |
| `sequence` | number | Per-chain monotonically increasing `i64`. Genesis = 0. |
| `signatures` | array | v1: always `[]`. v2: one object per signer (see Section 3). |
| `source_url` | string | Where the data came from. |
### 1.2 `data` size cap
`MAX_DATA_BYTES = 65_536` enforced after canonicalization. Exceeding it
raises `TrustyError::DataTooLarge`. Put blobs in object storage and reference
them by `{uri, sha256}` inside `data` -- do not inline them.
---
## 2. Dual hash
Each row carries two hashes. Both are computed in `trusty-core/src/hash.rs`
and are byte-identical to the Python reference.
### 2.1 `row_hash` -- SipHash-2-4
- Keyed with the 16-byte `SecretKey` (split into `k0` / `k1` as two
little-endian `u64`s, `bytes[0..8]` and `bytes[8..16]`).
- Input: the exact canonical payload bytes (`raw_payload` above).
- Output: 64-bit digest rendered as **lowercase hex**, **16 chars**, wire
representation derived from `format!("{:016x}", digest)`. This is a
big-endian-style hex rendering of the integer value. **BE hex is
canonical; any legacy LE rendering (e.g. earlier threadkeeper code) is
non-canonical and must be adapter-converted when crossing the boundary.**
See tag `s2_decision_be_encoding_canonical`.
### 2.2 `anchor_hash` -- SHA-256
Input byte layout:
```
row_hash_bytes || 0x00 || entity_id_bytes || 0x00 || chain_id_bytes
```
All three text fields are UTF-8. Output is lowercase hex, **64 chars**.
Null bytes are domain separators between fields -- do not drop them.
The dual-hash shape (keyed SipHash + keyless SHA-256 with null-byte domain
separation) is locked in `trusty-core/src/hash.rs` and covered by the
`anchor_hash_*` tests there. It is the only hash layout trusty recognises.
### 2.3 Why two hashes
SipHash-2-4 is a keyed fingerprint: cheap, fast, tamper-evident against
adversaries who do not hold the key. It is the chain-integrity primitive.
SHA-256 is the cryptographic anchor: public, collision-resistant, suitable
for external receipts and cross-system reference. The anchor is what you
publish or notarize; the row hash is what you use to link the chain.
Dual hash is non-negotiable -- do not collapse to one. See
`CLAUDE.md#design-rules`.
---
## 3. v2 signatures
v2 adds authenticated signatures over the payload. v1 is unchanged.
### 3.1 Shape of a signature entry
```json
{"alg":"ed25519","pk":"<hex>","sig":"<hex>"}
```
- Keys alphabetical (enforced by recursive canonicalization).
- `alg`: today always `"ed25519"`. Future algorithms add new literal values,
not OIDs.
- `pk`: hex-encoded public key bytes (32 bytes for Ed25519 -> 64 hex chars).
- `sig`: hex-encoded signature bytes (64 bytes for Ed25519 -> 128 hex chars).
`signatures` is an array so multi-signer is a pure extension -- just append
additional entries. Verifier iterates and validates each independently.
### 3.2 The signing-view algorithm
The core problem: if `row_hash` commits to the payload, and the payload
contains the signature, the signature cannot sign the final bytes (it would
have to contain itself).
Solution -- sign a canonical view with `signatures` forced empty, then
splice the signature in, re-canonicalize, and hash the spliced bytes. Both
sides of the envelope commit to each other:
**Sign (emission)**:
1. Start from caller's `PayloadFields`.
2. Force `payload_version = 2`, `signatures = []`.
3. Build the **signing view**: canonical bytes with `signatures=[]`.
`payload::signing_view_bytes(&fields)`.
4. Signer produces `sig = sign(signing_view_bytes)`.
5. Splice `{alg, pk, sig}` (all hex-encoded) into `signatures`.
6. Re-canonicalize -- these are the **final stored bytes** (`raw_payload`).
7. `row_hash = SipHash-2-4(key, raw_payload)`.
8. `anchor_hash = SHA-256(row_hash || 0x00 || entity_id || 0x00 || chain_id)`.
The row_hash therefore commits to the signature; the signature commits to
everything *except* itself (via the signing view).
**Verify (consumption)**:
1. Parse `raw_payload` back into `PayloadFields` (`chain::parse_raw_payload`).
2. If `payload_version < 2` or `signatures` is empty -> skip signature
check. v1 rows and unsigned v2 rows pass the signature stage by design.
3. Rebuild the signing view by replacing `signatures` with `[]` and
re-canonicalizing.
4. For each signature entry:
- Dispatch on `alg`. Unknown -> `UnknownSignatureAlg { alg }`.
- Hex-decode `pk` and `sig`. Decode failure -> `SignatureMalformed`.
- `Ed25519Verifier::verify(&pk, signing_view_bytes, &sig)`. Failure ->
`SignatureInvalid`.
5. `row_hash` is checked **independently** against the full stored bytes.
Tampering with just the sig bytes fires `RowHashMismatch`; recomputing
row_hash to hide the sig-tamper still fires `SignatureInvalid`. Attackers
lose both ways.
### 3.3 Worked example (conceptual)
Starting fields (abbreviated):
```json
{
"chain_id": "chain_acme_000001",
"data": {"temp_c": 21},
"entity_id": "acme",
"payload_version": 2,
"prev_hash": "genesis",
"sequence": 0,
"signatures": [],
...
}
```
Signing view = canonical bytes of that object exactly as shown (with all
other canonical fields populated).
Signer produces `sig` over those bytes. The final stored bytes:
```json
{
"chain_id": "chain_acme_000001",
"data": {"temp_c": 21},
"entity_id": "acme",
"payload_version": 2,
"prev_hash": "genesis",
"sequence": 0,
"signatures": [{"alg":"ed25519","pk":"ab...cd","sig":"12...ef"}],
...
}
```
`row_hash = siphash24(key, <final bytes>)`. This is what lands in the DB.
### 3.4 Nested object canonicalization (why it matters for v2)
Signature entries are JSON objects. They MUST be canonicalized (keys sorted)
before serialization, otherwise `serde_json::preserve_order` leaks caller
insertion order into the canonical bytes and different emitters produce
different `row_hash` for the same logical signature. `build_payload`
explicitly canonicalizes the signatures array via the recursive
`canonicalize` helper. See tag
`s5_learn_preserve_order_requires_canonicalize_nested`.
---
## 4. VerifyError taxonomy
`verify_links` returns `VerifyReport { ok, row_count, errors }`. Errors
carry `{sequence, chain_id, kind}`; the kinds relevant to payload format:
| Kind | Trigger |
|------------------------|---------------------------------------------------------------------|
| `PrevHashMismatch` | `link.prev_hash` != previous row's `row_hash` (or `genesis` on first row). |
| `MissingRawPayload` | Non-BREAK row with no `raw_payload`. |
| `InvalidRawPayload` | `raw_payload` does not parse as JSON or is missing required fields. |
| `MissingRowHash` | Non-BREAK row with no `row_hash`. |
| `RowHashMismatch` | Recomputed SipHash != stored `row_hash`. |
| `AnchorHashMismatch` | Recomputed SHA-256 != stored `anchor_hash` (when stored). |
| `SignatureInvalid` | v2: Ed25519 verify failed against signing view. |
| `SignatureMalformed` | v2: signature entry not a JSON object, pk/sig hex decode failed. |
| `UnknownSignatureAlg` | v2: `alg` field not recognized (only `ed25519` today). |
BREAK markers carry no hashes and reset `prev` to `genesis` for the next
segment. They are not counted in `row_count`.
---
## 5. API surface
### 5.1 `trusty-core` (pure library)
```rust
use trusty_core::{
compute_record, compute_record_signed,
Signer, Ed25519Signer,
PayloadFields, SecretKey,
verify_links,
};
// v1 path -- unsigned.
let rec = compute_record(&fields, &key)?;
// rec.raw_payload has "payload_version":1, "signatures":[]
// v2 path -- signed with any Signer impl.
let signer = Ed25519Signer::generate();
let rec = compute_record_signed(&fields, &key, &signer)?;
// rec.raw_payload has "payload_version":2 and one signatures entry.
// Caller's fields.payload_version and fields.signatures are overridden.
// Verify (version-agnostic).
let report = verify_links(&entity_id, &links, &key);
assert!(report.ok);
```
The `Signer` trait is byte-oriented:
```rust
pub trait Signer {
fn sign(&self, msg: &[u8]) -> Vec<u8>;
fn public_key_bytes(&self) -> Vec<u8>;
}
```
HSM or remote-signer integrations implement this trait directly; no
ed25519-dalek dependency leaks into chain code.
### 5.2 `trusty-sqlite` (storage adapter)
```rust
let store = Trusty::open(path, key)?;
// v1 append
let receipt = store.append(entity, data, source_url)?;
// v2 append -- threads Option<&dyn Signer> through the append pipeline
let receipt = store.append_signed(entity, data, source_url, &signer)?;
// Custom extraction_method
store.append_with_method(entity, data, source_url, method)?;
store.append_with_method_signed(entity, data, source_url, method, &signer)?;
```
Mixed v1/v2 chains verify fine; dispatch is per-row on stored
`payload_version`. You can flip signing on/off per-row at will.
---
## 6. Locked invariants
- **v1 byte-shape is frozen.** Same field set, same canonical ordering, same
`hash_alg` string, `signatures:[]`, `payload_version:1`. Tag:
`s1_invariant_payload_v1_canonical`.
- **v1 is preserved under v2.** v2 was shipped additive-only in S5 --
calling `compute_record` on a v1 `PayloadFields` emits exactly the same
bytes it did before v2 existed. Tag: `s5_invariant_v1_byte_shape_preserved`.
- **row_hash BE-hex encoding is canonical.** Decision `s2_decision_be_encoding_canonical`.
- **Dual hash inputs are locked.** SipHash-2-4 keyed + SHA-256 unkeyed with
null-byte domain separation. Tag: `s1_invariant_payload_v1_canonical`.
- **Signature algorithm string is a literal.** No OID dance, no algorithm
negotiation. Adding an algorithm = adding a new literal value in verify
dispatch.
- **row_hash commits to the signature bytes** (v2). Anything that would make
the signature not affect `row_hash` is a regression.
New algorithms, new fields, new versions must be purely additive and must
leave v1 bytes byte-identical. Run `cargo test -p trusty-core` and inspect
the `v1_*` tests before and after any payload-layer change.

237
docs/product-use-cases.md Normal file
View File

@@ -0,0 +1,237 @@
# Trusty — Product Use Cases and Beneficiaries
A non-technical overview of what Trusty enables, the products it unlocks, and
who benefits. This is the "what it does for the customer" view, not the
"how it works under the hood" view.
---
## What Trusty Is, In One Paragraph
Trusty is a tamper-evident audit layer. Any data that flows through it — a
sensor reading, a state transition, a decision, an agent action — gets sealed
into a chain that cannot be silently edited after the fact. If anyone changes,
inserts, deletes, or reorders a single record, the chain visibly breaks. That
break is itself an event the system can alert on. It is deliberately small,
cheap to run, and embeddable anywhere data needs to be trustworthy.
The business model is commodity-priced, high-margin, volume-driven. Trusty is
the door-opener. Every application that sits on top of it — carbon-credit
SaaS, compliance monitoring, AI agent accountability, data marketplaces, and
Threadkeeper — rides the same audit spine.
---
## 1. Carbon Credits You Can Actually Insure
**The problem.** Carbon credits are only worth what someone will pay for them,
and insurers will not underwrite credits backed by sensor data that could have
been quietly edited after the fact.
**What Trusty enables.** Every environmental reading from every sensor gets a
tamper-proof timestamp and fingerprint the moment it is recorded. If anyone
changes a single number later, it shows immediately and publicly. Climate
models (via NVIDIA Omniverse / Earth-2) then ingest data that is provably
untampered.
**Who benefits.**
- Carbon-credit SaaS operators — can now sell credits that insurers will
underwrite.
- Insurers — can price carbon-credit policies with actual evidentiary
foundation.
- Buyers / offset purchasers — know the credit represents real, measured
reduction, not a spreadsheet.
- Regulators — can audit the full chain without trusting the operator.
---
## 2. IoT Sensor Networks That Prove Themselves
**The problem.** With thousands of sensors in the field — pipelines, factories,
farms, power grids, cold chains, emissions monitors — you cannot watch them
all. A malfunctioning, spoofed, or tampered sensor can go undetected for
months, and the financial, regulatory, and reputational cost is enormous.
**What Trusty enables.** Every sensor reading is chained to the one before
it. The absence of a valid reading is itself the alarm — you do not need a
separate intrusion detector. Trusty handles fleet-scale workloads
(approximately 19,000 readings per second per host), so thousands of devices
can write into the same audit trail without slowing anything down.
**Who benefits.**
- Industrial operators (oil and gas, manufacturing, utilities) — catch
tampering and device failure in real time.
- Insurance and reinsurance — underwrite sensor-driven policies with
confidence.
- Supply chain and cold-chain operators — prove goods were handled correctly
end to end.
- Compliance and safety teams — turn "we think the sensors worked" into "we
can prove it."
---
## 3. Active Compliance Monitoring and the Liability Flip
**The problem.** In regulated industries — pharma, aviation, finance, food
safety, medical devices — disputes come down to "what did your system
record?" Most enterprise logs are editable, which leaves the company
carrying the liability.
**What Trusty enables.** Pair Trusty with a rule set that defines which
transitions are *legal* ("a dose can only be administered after a
verification step"). Every legitimate transition is sealed into the chain.
Anything outside the rules cannot produce a valid entry. The chain becomes
legal-grade evidence of what actually happened.
**The liability flip.** "It is in the chain" or "it is not in the chain" is
a stronger claim in a dispute than any editable database log. The burden of
proof moves from the operator (who currently has to defend the integrity of
their logs) onto the claimant. This is the single strongest commercial
story Trusty has.
**Who benefits.**
- Pharma and medical device manufacturers — audit-ready by default for FDA,
EMA, and equivalent regulators.
- Aviation and aerospace — tamper-evident maintenance and flight-ops records.
- Financial services — provable trade, order, and settlement history.
- Legal teams — reduce discovery cost and win disputes on evidentiary
strength.
- Insurers of the above — lower claim cost when the audit trail is
unambiguous.
---
## 4. Audit-Ready by Default: The Proof Bundle
**The problem.** Audits, regulatory reviews, insurance claims, and legal
disputes all require handing evidence to someone who does not trust you.
Today that is a scramble of spreadsheets, screenshots, and lawyers, and the
recipient still has to take your word for most of it.
**What Trusty enables.** Export a single sealed bundle covering any time
window or entity. The recipient — auditor, insurer, regulator, opposing
counsel — verifies it independently on their own equipment, with no access
to your systems and no need to trust the operator. The bundle carries
everything required: the records, the cryptographic anchors, and the
verification keys.
**Who benefits.**
- Compliance and audit teams — turn compliance from a project into a button.
- Insurers and claims adjusters — verify losses without site visits or
vendor cooperation.
- Regulators — receive evidence in a standard, self-verifying format.
- Law firms — deliver and receive discovery material that is demonstrably
authentic.
- Any business with "prove it" as part of its sales cycle — verifiability
becomes the product.
---
## 5. Data Marketplace Provenance
**The problem.** In peer-to-peer and decentralized data marketplaces,
publishers can silently swap individual pieces of a dataset even when each
piece is individually signed — because signatures do not prove ordering or
history.
**What Trusty enables.** Every slice of the dataset is linked to its
neighbors so any substitution, deletion, or reordering is immediately
visible. You can prove not just "this piece is authentic" but "this is the
complete history, in order, unchanged."
**Who benefits.**
- Distant Star (Eric's decentralized data-network protocol) — gains a
provenance layer it currently lacks.
- Data publishers — can charge a premium for verifiable datasets.
- Data buyers (enterprise, scientific, government) — can finally adopt
decentralized data sources for regulated work.
- Validators and data curators — their attestations now anchor to a chain
state that proves what they reviewed has not changed.
---
## 6. Provable AI Agents and Agent Swarms
**The problem.** When AI agents make decisions — trading, legal, medical,
autonomous systems, customer-facing automation — regulators, customers, and
insurers increasingly need to prove not just *what the agent did* but *what
it was paying attention to* when it did it. Without that, AI cannot be
adopted at scale in regulated environments.
**What Trusty enables.** Every agent carries parallel audit chains — one
per dimension being monitored (attention, uncertainty, inter-agent
interaction, input context, output action). Any drift, hallucination, or
off-policy behavior shows up as a chain break on the relevant dimension.
You get an audit trail not just of agent outputs but of agent *state*.
**Who benefits.**
- AI platform operators — ship AI into regulated markets they currently
cannot enter.
- Enterprises adopting AI — get an accountability layer their compliance
teams will accept.
- Regulators — gain a standard way to inspect agent behavior after the fact.
- AI safety and alignment researchers — have a production-grade substrate
for behavioral monitoring.
---
## 7. Threadkeeper
**What Threadkeeper is.** Threadkeeper is Eric's desktop knowledge /
conversation-capture application. It records, organizes, and reasons over
long-running threads of work — transcripts, notes, decisions, research
artifacts — across sessions and projects.
**The problem Threadkeeper has.** A knowledge tool is only as trustworthy as
the history it presents. If a user, a sync bug, or an automated process can
silently rewrite past entries, the tool cannot be used for anything where
"what was said and when" matters — legal matters, investigative work,
research that will later be published, client-facing deliverables, or
regulated professional practice.
**What Trusty enables for Threadkeeper.**
- Every captured entry is sealed on write. The historical record is
demonstrably the record that was originally created.
- Any external party — a client, a court, a reviewer — can be handed a
Proof Bundle and verify the thread's integrity independently.
- Cross-thread references (entry A cites entry B at a specific point in its
history) are themselves tamper-evident, so citation integrity is
preserved.
- When AI assistance is layered into Threadkeeper (summarization,
reasoning, agentic workflows), the agent's activity rides the same chain
— so "what the assistant did, when, and why" is part of the same audit
trail as the human-authored content.
**Who benefits inside the Threadkeeper story.**
- Lawyers, consultants, and advisors — deliverables built on Threadkeeper
are audit-ready and defensible by default.
- Investigators and journalists — source material and working notes carry
provable provenance.
- Researchers — lab notebooks, experiment logs, and authorship trails are
tamper-evident.
- Solo professionals and small firms — get enterprise-grade evidentiary
rigor without enterprise-grade tooling cost.
- Anyone handing work product to a skeptical third party — the product
ships with its own proof of integrity.
**Why this matters commercially.** Threadkeeper plus Trusty is the
reference implementation of "knowledge work that proves itself." It is the
most visible, user-facing demonstration of the Trusty substrate, and it is
the story that translates the technology into something non-technical
buyers intuitively understand.
---
## The Common Thread
Trusty is deliberately narrow — a commodity audit layer, not a platform.
Low per-unit cost, high margin, easy to embed anywhere trustworthy data
matters. Once embedded, every application layer on top — carbon credits,
compliance monitoring, agent accountability, data marketplaces,
Threadkeeper — rides the same audit spine and inherits the same
evidentiary guarantees.
The consistent benefit across every use case: **the burden of trust shifts
away from the operator and onto the math.** Customers stop having to
convince counterparties their logs are honest, because the logs prove
themselves.

96
docs/scope-grammar.md Normal file
View File

@@ -0,0 +1,96 @@
# Scope grammar
The string format `trusty_policy.permits()` matches against. Two repos
depend on it, so it is written down here rather than inferred from the
implementation.
## The rule
```sql
CREATE OR REPLACE FUNCTION trusty_policy.covers(scope TEXT, resource TEXT)
RETURNS BOOLEAN
LANGUAGE sql IMMUTABLE PARALLEL SAFE
AS $fn$
SELECT scope IS NOT NULL
AND resource IS NOT NULL
AND (resource = scope OR starts_with(resource, scope || '/'))
$fn$
```
A scope covers a resource when they are **equal**, or when the resource
begins with the scope **followed by a `/`**.
That is the whole grammar. There are no wildcards, no globs, and no
pattern syntax — deliberately.
## Shape
```
<class>:<id>[/<segment>]*
```
| part | example | rule |
|---|---|---|
| class | `board` | opaque to trusty |
| id | `9f2` | opaque to trusty |
| segment | `calendar`, `private` | separated by `/` |
Only the `/` is load-bearing. `<class>:<id>` is a convention the
consumer chooses; trusty treats everything before the first `/` as one
opaque string.
## What follows from it
**`/` is the only separator.** `board:9f2/calendar` covers
`board:9f2/calendar/private`. It does **not** cover
`board:9f2/calendar-private`, because the character after the scope is
`-`, not `/`.
**A near-miss prefix does not match.** `board:o` does not cover
`board:other/calendar`. Without the separator in the concatenation it
would, which is why the separator is in there and not an afterthought.
**No wildcards.** A scope of `%` is a scope literally named `%`, and a
scope containing `_` matches only a literal underscore. `starts_with` is
used rather than `LIKE` precisely so an attacker-influenced scope cannot
widen itself into a pattern.
**Case and Unicode are exact.** `Board:9f2` is a different scope from
`board:9f2`. There is no normalisation, folding, or trimming anywhere in
the path. Emit exactly what you mean to match.
**No trailing slash.** `board:9f2/calendar/` is a *different* scope from
`board:9f2/calendar`, and covers `board:9f2/calendar//private` rather
than anything useful. Do not emit one.
**Exclusions use the same rule.** An exclusion of
`board:9f2/calendar/private` shuts that resource and everything beneath
it, checked before coverage and across every live grant of the subject
regardless of tier.
## What a producer must guarantee
Anything writing a `resource_scope` column, or issuing a grant, owes the
following. Trusty cannot check them, because the parts are opaque to it:
1. Segments are emitted in the same shape both sides expect. An
exclusion of `board:9f2/calendar/private` protects nothing if rows
carry `board/9f2/calendar/private`.
2. No segment contains a `/` of its own. A segment that does silently
becomes two levels, and a grant one level up now covers it.
3. Ids are not user-controlled free text. `board:<user-supplied>` lets
someone name a board `9f2/calendar` and inherit a scope they were
never granted.
Point 3 is the one that bites. If ids can contain `/`, the grammar has
no boundary and coverage becomes forgeable.
## What trusty deliberately does not do
- Parse, validate or normalise a scope
- Attach meaning to `class` or `id`
- Enforce a maximum depth
The engine takes two opaque strings and asks whether one contains the
other. Keeping it that narrow is what lets the consumer own the
namespace — and is why the guarantees above are the consumer's to keep.

296
docs/threat-model.md Normal file
View File

@@ -0,0 +1,296 @@
# Trusty threat model
What trusty detects, what it does not detect, what the trust boundaries
are, and how the system behaves under key compromise. Read alongside
`docs/payload-v2.md` for the on-wire format details this doc references.
This is a living document. If you add a primitive (new condition, new
signature algorithm, new break reason) that changes any property here, this
file gets updated in the same session.
---
## 1. Assets under protection
| Asset | What it represents |
|-----------------------|------------------------------------------------------------------|
| Chain integrity | The append-only link from genesis through every row's `prev_hash`. |
| Row authenticity (v2) | The claim that a specific signer endorsed a specific row payload. |
| Event history | That every mutation -- benign or adversarial -- was recorded. |
| External anchors | SHA-256 `anchor_hash` values suitable for publication / notary. |
Trusty is an **integrity and provenance** layer. It is not a confidentiality
layer, not an availability layer, and not an access-control layer.
---
## 2. Adversary model
We consider an attacker who has one or more of:
- **Read access to the SQLite file** (or the underlying bytes).
- **Write access to the SQLite file** (can modify rows, re-order, delete,
insert fabricated rows).
- **Control of the upstream source** feeding `data` into `append()`.
- **Control of `detected_at` / `received_at` values** when submitting.
- **Possession of one or both cryptographic keys** -- the 16-byte SipHash
`SecretKey` used for `row_hash`, and/or the Ed25519 signing key used for
v2 signatures.
We do not consider:
- Side-channel attacks on SipHash / Ed25519 implementations. The underlying
crates (`siphasher`, `ed25519-dalek`) are treated as correct.
- Compromise of the host OS kernel or memory bus -- below the trust
boundary.
- Quantum-era attacks on Ed25519. Out of scope; algorithm agility is
reserved for a future version bump.
---
## 3. The chain-break-as-event primitive
Trusty's central design choice differs from most provenance systems: when a
break condition fires, trusty **records the break as a row** (`event_type =
"BREAK"`, `break_reason` set) and **starts a new chain segment** from
genesis for the next append. It does not refuse the write and does not
silently repair.
Implications for the threat model:
- Tampering cannot erase its own trace. Even an attacker with write access
who corrupts a row triggers a BREAK at the next honest append, which is
itself an auditable event.
- Verification after the fact surfaces the break: `verify_links` returns
`ok = true` for a chain with BREAK markers (they are valid structural
separators), but the presence of BREAK rows is itself the signal.
Consumers of verify reports MUST inspect BREAK markers and their
`break_reason`, not treat a passing verify as clean history.
- An attacker who controls writes AND holds both keys can forge an entire
new segment that looks benign. BREAK markers detect tampering only when
a subsequent honest append occurs. See Section 6 for the defense.
---
## 4. Detection surfaces
### 4.1 Chain structural checks (`verify_links`)
| Surface | Detects | Miss condition |
|-----------------------|----------------------------------------------------------|----------------------------------------------------------------|
| `PrevHashMismatch` | Row reordering, deletion mid-chain, fabricated row with wrong link. | Attacker re-stitches prev_hash and also forges row_hash (requires SecretKey). |
| `RowHashMismatch` | In-place payload mutation without re-hashing. | Attacker recomputes row_hash (requires SecretKey). |
| `AnchorHashMismatch` | Tampering with stored anchor when row_hash/entity/chain is intact. | Attacker recomputes anchor (requires access to entity_id + chain_id only, no key). |
| `MissingRowHash` / `MissingRawPayload` | Rows with structural fields dropped. | None at this layer. |
### 4.2 Break conditions (at append time, `crates/trusty-core/src/conditions.rs`)
| Condition | Fires on | `BreakReason` |
|---------------------|--------------------------------------------------------------------------|--------------------------------|
| `HashIntegrity` | Stored tip hash != computed chain-tip hash (pre-append). | `TAMPER_DETECTED` |
| `SequenceContinuity`| `sequence` is not `last_sequence + 1`. | `SEQUENCE_GAP` |
| `SourceDomain` (opt-in) | `source_url` not in configured trusted-domains list, `hard = true`. | `SOURCE_CHANGE` |
First failing condition wins; remaining conditions do not run. Custom
conditions compose via the `Condition` trait.
### 4.3 v2 signature checks (`verify_row_signatures`)
Only applied when `payload_version >= 2` and `signatures` is non-empty.
| Surface | Detects |
|-------------------------|-------------------------------------------------------------------|
| `SignatureInvalid` | Signature does not verify against the reconstructed signing view. |
| `SignatureMalformed` | Signature entry not a JSON object; pk/sig hex decode failure. |
| `UnknownSignatureAlg` | `alg` field is not `ed25519` (only algorithm supported today). |
### 4.4 Cross-check KV store (`trusty-sqlite`)
`trusty-sqlite` mirrors `(anchor_hash -> row_id)` into a `hash_index` table
separate from the main `provenance_envelope` table. A modified anchor on
the primary row is caught when verification walks the cross-check.
Byte-layout matches the Python reference `cross_check_kv_store`.
---
## 5. Ed25519 trust boundary (v2)
The v2 signing-view algorithm establishes a specific trust relationship:
- **Signer claims**: "at the time of signing, this `{entity_id, chain_id,
sequence, prev_hash, data, detected_at, received_at, source_url,
extraction_method, hash_alg, payload_version}` tuple was true." The
signature covers the **signing view** (canonical bytes with
`signatures=[]`).
- **Signer does not claim** row_hash correctness. row_hash is produced by
the infrastructure holding the SipHash `SecretKey`, which may be a
different party.
- **Infrastructure claims** (via `SecretKey`): "I received these exact bytes
and they chain from the previous tip." Infrastructure cannot forge the
Ed25519 signature without the signing key.
This split is deliberate: it lets data-producing entities sign with
hardware-bound Ed25519 keys while the trusty storage layer runs with a
commodity SipHash key. Compromising one key is not enough to silently
rewrite history. See Section 6.
### 5.1 Row-hash-commits-to-signature property
`row_hash = SipHash(SecretKey, canonical_bytes_with_signature_spliced_in)`.
Therefore row_hash is a function of the signature bytes themselves.
Tampering strategies and their failure modes:
| Attacker action | What fires |
|-----------------------------------------------|---------------------------------------------|
| Change sig bytes, keep row_hash unchanged. | `RowHashMismatch`. |
| Change sig bytes, recompute row_hash. | `SignatureInvalid` (sig no longer verifies over signing view). |
| Change data, recompute sig (no Ed25519 key). | Cannot produce sig -> `SignatureInvalid`. |
| Change data + sig + row_hash (has both keys). | **Passes.** See Section 6. |
| Strip signatures array to empty. | `RowHashMismatch` (canonical bytes changed). Downstream also loses authenticity claim. |
| Downgrade `payload_version` to 1. | `RowHashMismatch`. v1 re-emit is a different byte sequence. |
---
## 6. Key compromise scenarios
Trusty has two key materials with different blast radii:
### 6.1 SipHash `SecretKey` compromise (one key, symmetric, per-installation)
Attacker can:
- Recompute `row_hash` for any fabricated payload.
- Walk the chain forward: craft a new row with correct `prev_hash` and
correct `row_hash`, pass `verify_links`.
- Generate valid `anchor_hash` values (anchor_hash requires no key -- it is
public SHA-256; attacker can always do this step).
Attacker cannot (in a v2 chain):
- Forge an Ed25519 signature. The `SignatureInvalid` error will fire on any
row they fabricate claiming a signer they do not control.
- **Therefore: v2 signed chains survive SipHash key compromise.** The break
surfaces as `SignatureInvalid`, not structural mismatch, but it is still
detected.
Mitigation: rotate SipHash key on suspected exposure. Old rows remain
verifiable with the old key; new rows use the new key. Document rotation
boundary in operations.
### 6.2 Ed25519 signing key compromise (per-signer, asymmetric)
Attacker can:
- Sign arbitrary data under the compromised public key. Any verifier who
trusts that `pk` will accept the forged signatures.
Attacker cannot:
- Compute `row_hash` without the SipHash key.
- **Therefore: signed chains survive Ed25519 key compromise.** The break
surfaces as `RowHashMismatch` when the storage-layer key is different
from the attacker's compromised key.
Mitigation: revoke compromised `pk`; verifiers maintain a revocation list
and reject signatures from revoked keys after the revocation timestamp.
Trusty does not ship a revocation mechanism today -- this is a downstream
consumer concern.
### 6.3 Dual compromise (both keys)
Attacker can forge a self-consistent chain end-to-end. This is the only
scenario where trusty's local primitives fail silently.
Defense-in-depth:
- **Publish `anchor_hash` externally.** Any row whose anchor was published
(blockchain, transparency log, notary, etc.) before the compromise
window cannot be retroactively rewritten. Post-publication tampering is
caught by comparing local anchor to the external record.
- **Distribute keys across trust boundaries.** SipHash key belongs to the
storage operator; Ed25519 key belongs to the data-producing entity. An
attacker who wants both must compromise both parties.
- **Cross-check with siblings.** Where a chain is mirrored (threadkeeper,
Distant Star), discrepancies between the local chain and the mirror are
evidence of tampering on one side, independent of local key material.
### 6.4 What dual compromise still cannot do
Even with both keys, an attacker cannot:
- Forge a row whose `anchor_hash` has already been published externally and
whose copy is archived outside the trusty DB. Anchor publication is the
ultimate integrity witness.
- Hide a historical BREAK marker if any consumer has previously read and
cached the chain (independent copies surface the diff).
---
## 7. Out-of-scope threats
Trusty does **not** protect against these and callers must layer additional
controls where they matter:
- **Backdating.** `detected_at` and `received_at` are caller-provided
strings. They are canonical bytes inside the payload and covered by
row_hash + signature, but they are unverifiable claims about when
something happened. Use a trusted time source (TSA, blockchain timestamp)
externally if the timing claim is load-bearing.
- **Data authenticity upstream of ingest.** `data` is whatever the caller
hands `append()`. Trusty certifies that the byte sequence was received
and chained; it does not certify that the byte sequence reflects reality.
IoT sensor integrity, API-to-DB pipelines, etc. are upstream concerns.
- **Availability / DoS.** Trusty makes no liveness guarantees. An attacker
with write access can flood the DB or corrupt the file; trusty detects
tampering on verify but does not prevent service degradation.
- **Confidentiality.** Payloads are stored in cleartext canonical JSON.
Encrypt the DB file (or the `data` field via caller-side envelope
encryption) if confidentiality is required. `row_hash` is computed over
the cleartext.
- **Access control.** Any process with file-system access can call
`append()`. Gate ingestion at a higher layer (API, auth middleware).
- **Replay.** A signed row can be inserted into a different chain if the
attacker also holds the SipHash key for that target chain. Chain-id and
entity-id are in the signing view, so cross-chain replay requires
fabricating anchor_hash with matching entity + chain strings -- detected
by `AnchorHashMismatch` only if the original row has a different anchor.
Defense is at the insertion gate.
---
## 8. Operational guidance for integrators
Short checklist for consumers (threadkeeper, Distant Star, direct users):
1. **Verify reports are not pass/fail.** Always inspect `errors` and walk
BREAK markers. A chain with five BREAK events and `ok = true` is an
integrity event, not a clean bill.
2. **Publish anchor_hash early and often.** Anchor publication is the
single strongest defense against dual-key compromise.
3. **Separate key custody.** SipHash key lives with the storage operator;
Ed25519 keys live with signing entities. Do not collocate them in the
same process or HSM slot unless you have a specific reason.
4. **Maintain signer-key registries.** Consumers decide which `pk` values
they trust. Trusty does not; it only verifies signatures against the
pk field in the payload.
5. **Treat `detected_at` / `received_at` as claims, not facts.** If timing
is adversarial, pair with an external TSA.
6. **Do not compare row_hash across installations.** The 16-byte SipHash
key differs per installation, so the same logical payload has different
row_hash values across deployments. Compare `anchor_hash` for
cross-installation equivalence, since that is SHA-256 and keyless.
---
## 9. Related locked surfaces
Changes to these surfaces require a threat-model review and must leave the
listed invariant tests green:
- Dual-hash primitive (SipHash-2-4 + SHA-256). Tag
`s1_invariant_payload_v1_canonical`.
- v1 canonical byte shape. Tag `s5_invariant_v1_byte_shape_preserved`.
- row_hash BE-hex encoding. Tag `s2_decision_be_encoding_canonical`.
- Signing-view algorithm (row_hash commits to signature). Tag
`s5_decision_v2_signatures_inline_row_hash_commits`.
- Ed25519 determinism (no randomized-sig variant). Tag
`s4_invariant_ed25519_determinism`.
- Three-table SQLite schema + `hash_index` cross-check.
Anything that weakens a detection surface is a regression.

File diff suppressed because it is too large Load Diff

92
docs/twin-coupling.md Normal file
View File

@@ -0,0 +1,92 @@
# Twin Coupling -- Data Convention
## Problem
A digital-twin computation ingests readings from one or more sensor
chains and produces a derived result. The result is appended to its own
trusty chain (separate `entity_id`). How does an external verifier
confirm which sensor rows fed the computation?
## Convention
The twin row carries a `source_refs` array inside its `data` field:
```json
{
"computation": "rolling_avg_temp_c",
"result": 21.37,
"source_refs": [
{"chain_id": "chain_sensor-001_...", "sequence": 0, "row_hash": "ae1cfe445cda1f6d"},
{"chain_id": "chain_sensor-001_...", "sequence": 1, "row_hash": "dee9856d170787cf"},
{"chain_id": "chain_sensor-001_...", "sequence": 2, "row_hash": "26cd125f051243cf"}
]
}
```
Each entry is:
| Field | Source | Purpose |
|------------|---------------------------------|--------------------------------------------|
| `chain_id` | `AppendResult.chain_id` | Identifies which sensor chain |
| `sequence` | `AppendResult.sequence` | Row position within that chain |
| `row_hash` | `AppendResult.row_hash` | Tamper-evident fingerprint of the row |
Because `data` is already part of the payload that `row_hash` commits
to, the `source_refs` array is itself hash-protected. Altering any
reference after the fact invalidates the twin row's `row_hash` and
fails `verify_chain`.
## Verification flow (two ProofBundles)
An external verifier needs no database access -- only two
`ProofBundle` blobs exported via `Trusty::export_bundle`:
1. `sensor_bundle.verify(&sip_key)` -- confirms the sensor chain is
internally consistent.
2. `twin_bundle.verify(&sip_key)` -- confirms the twin chain is
internally consistent.
3. Walk twin rows; for each `source_refs` entry, look up the matching
row in `sensor_bundle.rows` by `chain_id` + `sequence` and assert
`row_hash` matches.
If all three checks pass, the verifier knows:
- The sensor readings are unaltered.
- The twin computation row is unaltered.
- The twin row commits to exactly those sensor row_hashes at write time.
See `crates/trusty-sqlite/examples/twin_coupling.rs` for a runnable
end-to-end demo.
## Design decisions
**Why `data` field, not a new `break_reason` variant.**
`break_reason` marks loss-of-continuity (tamper, gap, domain mismatch).
A twin derivation is a planned event, not an anomaly. Mixing the two
pollutes `breaks_since()` as an alerting surface and requires callers
to filter by break kind.
**Why not a separate `twin_reference` table.**
A join table outside the hash surface cannot be verified from a
ProofBundle alone. The verifier would need database access, breaking
the self-contained export contract. `source_refs` in `data` is
verifiable anywhere the bundle travels.
**What this does NOT prove.**
The convention proves the twin row committed to those row_hashes at
write time. It does not prove the computation is correct (the twin
operator could have computed a wrong average and still referenced the
right sensor rows). Computation correctness is out of scope for trusty;
it belongs to a separate audit or reproducibility layer.
## Multi-sensor / multi-chain refs
`source_refs` can mix `chain_id` values from different sensor entities.
The verifier simply exports a ProofBundle per sensor entity and checks
each ref against the appropriate bundle.
```json
"source_refs": [
{"chain_id": "chain_sensor-001_...", "sequence": 5, "row_hash": "..."},
{"chain_id": "chain_sensor-002_...", "sequence": 3, "row_hash": "..."}
]
```

140
docs/vocabulary.md Normal file
View File

@@ -0,0 +1,140 @@
# Vocabulary crosswalk
Our words, the standard words, and which is which. Written so an
assessor can follow their own vocabulary into this system, and so we can
keep a term where ours is genuinely sharper without that being an
obstacle.
This is the ordinary compliance practice of a **mapping**: NIST
publishes the same thing between 800-171 and 800-53. The value is not in
picking a winner. Picking one would be wrong anyway — XCCDF is
configuration scanning, C2PA is media provenance, in-toto is software
supply chain, and none was built for a P2P data mesh.
**On sourcing.** Rows marked ✓ were checked against the specification
during this session. Rows marked ~ are from working knowledge and want
confirming before they go in front of an assessor. A term cited wrongly
is worse than a term not cited, because a citation reads as authority.
---
## 1. Where we already use the standard word
Nothing to do here. Listed so a reader can stop looking.
| ours | standard | source |
|---|---|---|
| `principal` | principal | Kerberos, AWS IAM, Java security, NIST RBAC ~ |
| `delegation` | delegation | NIST RBAC, OAuth 2.0 ~ |
| `scope` | scope | OAuth 2.0 ~ |
| `grant` | grant | OAuth 2.0, XACML ~ |
| `Tier::sql_verb()` → SELECT/INSERT/UPDATE/DELETE | the SQL verbs | ISO SQL ✓ |
| `canonical_json()` | JSON Canonicalization Scheme | RFC 8785 ✓ |
| Ed25519, SHA-256, SipHash-2-4 | same | RFC 8032, FIPS 180-4 ✓ |
---
## 2. Where we invented a word and a standard exists
This is the list that matters.
### 2.1 `Stance` — the verdict
**Adopt the standard.** XCCDF is NIST's own, it is what a SCAP-literate
assessor reads, and our three values map exactly onto three of its nine.
| ours | XCCDF `rule-result` ✓ |
|---|---|
| `Clean` | `pass` |
| `Violated` | `fail` |
| `Unverifiable` | `unknown` |
XCCDF's full set is `pass`, `fail`, `error`, `unknown`, `notapplicable`,
`notchecked`, `notselected`, `informational`, `fixed`.
**The one reservation.** `Unverifiable` says *why* nothing is known —
the evidence could not be checked — where `unknown` covers that and
several other cases. Ours is more precise; theirs is recognisable. For a
verdict an outsider reads, recognisable wins.
**Prior art worth knowing:** OCSP answers the same shape with `good` /
`revoked` / `unknown` ~. Three values, and the third is "we cannot say"
— the same design decision, reached independently, which is a good sign
the three-value shape is right.
### 2.2 `Annotation` — a claim about a datum
| ours | C2PA ✓ | in-toto ✓ |
|---|---|---|
| `Annotation` | **assertion** — a statement the signer makes about the asset | **predicate** |
| the signed row carrying it | **claim** — the signed container grouping assertions | **Statement** (`_type`, `subject`, `predicateType`, `predicate`) |
| `ProofBundle` | **manifest** — assertions + claim + signature as one verifiable unit | — |
| `content_tag` | the asset | **subject** |
**Keep ours, and say what it maps to.** C2PA's *assertion* is the closest
single word, and the C2PA shape is genuinely similar: individual
statements, grouped and signed together. But their *claim* and *manifest*
have media-provenance meanings that would mislead here, and adopting one
word of a three-word system imports the confusion without the benefit.
The mapping is the deliverable, not a rename.
### 2.3 The hash names
| ours | standard | note |
|---|---|---|
| `row_hash` | Merkle leaf hash ~ | ours is a keyed MAC, theirs usually is not |
| `anchor_hash` | Signed Tree Head, RFC 6962 ~ | needs checking before it goes in a filing |
**Do not rename.** These are frozen byte shapes; the field names feed
hashed payloads, and renaming a key that feeds a hash silently breaks
every artifact already signed under the old spelling. See the lesson
`freeze-tokens-for-hashed-keys`.
### 2.4 Smaller ones
| ours | standard | verdict |
|---|---|---|
| `producer registry` | PKI **issuer** ~; C2PA **claim generator** ✓ | keep — ours is a registry of keys, not an issuer of certificates |
| `Tier` | NIST RBAC **operation** ~ | keep — ours is a *damage* taxonomy that happens to map 1:1 onto the SQL verbs, which "operation" does not convey |
| `content_tag` | in-toto **subject** ✓; content-addressing **CID** ~ | keep, map to `subject` |
---
## 3. Genuinely ours
No standard equivalent found. These are the ideas worth defending as
novel rather than translating.
- **chain-break-as-event.** A monitored condition failing is recorded as
a first-class row rather than raised as an error, so the violation is
evidence rather than something a log swallowed.
- **One chain per `(data, node)`.** Nobody writes to anybody else's
chain, so a node cannot forge a record it did not make, and the picture
is recovered by merging rather than by agreeing.
- **Tier → RLS with no translation layer.** The four tiers *are* the
four SQL verbs, so a grant compiles to a row-level-security predicate
directly. The translation layer is where an application's idea of a
permission and the database's drift apart.
- **`rows_valid` / `fully_signed` / `continuous` / `intact()`.** Four
separate questions instead of one boolean, because "did the signatures
check" and "was anything unsigned" and "was a break recorded" have
different answers and different consequences.
---
## 4. What to do with this
| | |
|---|---|
| **Rename now** | `Stance` → XCCDF's `pass` / `fail` / `unknown`. Days old, one consumer, cheap. |
| **Map, don't rename** | `Annotation`, `ProofBundle`, `content_tag`, `Tier`, `producer registry`. Load-bearing across crates and, for some, already in a committed migration. |
| **Never rename** | `row_hash`, `anchor_hash`, and any other key feeding a hashed payload. |
| **Defend as novel** | section 3. |
**Sources checked this session:** [NIST XCCDF 1.2
schema](https://csrc.nist.gov/schema/xccdf/1.2/xccdf_1.2.xsd) ·
[OpenSCAP manual](https://static.open-scap.org/openscap-1.2/oscap_user_manual.html) ·
[C2PA specification](https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html) ·
[in-toto Statement
layer](https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md)