1302 lines
59 KiB
Markdown
1302 lines
59 KiB
Markdown
# trusty-postgres -- Design Memo
|
|
|
|
Status: DRAFT. Pre-implementation. Author: design pass over the
|
|
LCG architecture-antipatterns lessons, the Store trait surface,
|
|
and the trusty-sqlite reference implementation. Reader: Eric.
|
|
|
|
This memo defines the contract, schema, and architectural decisions
|
|
for `trusty-postgres` BEFORE any code is written. The point of writing
|
|
it now is the meta-principle in `insight-LCG-ARCH-01`: features that
|
|
"we will add in v2" become 5-10x more expensive to retrofit than to
|
|
build in from day one. Every section below names the lesson it draws
|
|
from so the design's lineage is auditable.
|
|
|
|
The reference implementation to mirror is
|
|
`crates/adapters/trusty-sqlite/src/store.rs:1-955` and
|
|
`crates/adapters/trusty-sqlite/src/schema.rs:1-49`. The trait being
|
|
implemented lives at `crates/core/trusty-store/src/lib.rs:22-48`.
|
|
|
|
---
|
|
|
|
## 1. Goal and non-goals
|
|
|
|
### Goal
|
|
|
|
`trusty-postgres` is a sibling adapter crate (likely separate Gitea
|
|
repo `Modo-Melius/trusty-postgres`) that implements `trusty_store::Store`
|
|
and the higher-level `Trusty` operations against PostgreSQL 14+ with
|
|
optional TimescaleDB extension. It targets the deployment tier named in
|
|
`s14_insight_store_trait_multi_backend_landscape.toml`: "mid-scale
|
|
cloud, time-series query acceleration on received_at via hypertable."
|
|
|
|
The product framing comes from
|
|
`s14_insight_oid_anchored_chain_identity.toml`: trusty-postgres is the
|
|
"OID-anchored TrustyDB" surface for enterprise buyers who already run
|
|
Postgres + PKI infrastructure. The pitch is "your existing Postgres,
|
|
plus cryptographic proof of stream completeness/authenticity," not
|
|
"a new database." This framing forecloses any temptation to bundle
|
|
application logic, distributed coordination, or marketplace primitives
|
|
into the crate. It is a Store adapter and the higher-level `Trusty`
|
|
methods, nothing more.
|
|
|
|
### Non-goals
|
|
|
|
- Not a replacement for `trusty-sqlite`. SQLite remains the embedded
|
|
/ edge / single-process reference. Postgres targets multi-writer,
|
|
fleet-scale, or organizationally-required-Postgres deployments.
|
|
- Not a distributed-coordination layer. Multi-region, multi-master,
|
|
or consensus-flavored deployments belong in a hypothetical
|
|
`trusty-scylladb` or above. Postgres logical replication is fine
|
|
if the operator wants it; we do not invent any cross-cluster chain
|
|
reconciliation primitive here.
|
|
- Not an analytics surface. DuckDB / columnar read paths are a separate
|
|
adapter per the four-tier landscape tag.
|
|
- Not a migration of the locked v1 byte-shape. The point of the Store
|
|
trait is that `trusty-core` primitives are untouched. Any change that
|
|
would shift v1 bytes is out of scope by definition; see
|
|
`s5_invariant_v1_byte_shape_preserved` and `CLAUDE.md` "Don't Touch."
|
|
- Not a TCB-redefining crate. The trusted computing base added by
|
|
this adapter is: postgres + libpq + the chosen Rust driver. Document
|
|
this on page one of the README (per `learn-LCG-ARCH-13` / TCB
|
|
manifest discipline) and let it sit there as a deliberate cost.
|
|
|
|
### Relationship to Distant Star (DS)
|
|
|
|
trusty-postgres is orthogonal to the DS network protocol (dspm, dsnl,
|
|
dsutils, ds-cli -- the Monorepo-scratch stack). DS is a UDP gossip
|
|
transport with X-Wing KEM, exponential-decay hop probability, and
|
|
tag-based knowledge-network routing; it transports data, it does not
|
|
store it. trusty-postgres stores data behind the Store trait. The two
|
|
compose, they do not overlap.
|
|
|
|
Concrete composition points (relevant so this crate's API does not
|
|
accidentally foreclose them):
|
|
|
|
- **trusty-ds-fs (#10)** and **trusty-ds-network (#12)** may use
|
|
trusty-postgres as their persistence layer when a DS node is
|
|
deployed in the mid-scale cloud tier rather than at the edge. The
|
|
Postgres adapter must therefore expose a `stream_anchors_since` and
|
|
a ProofBundle export surface that DS handlers can call without
|
|
coupling to any internal detail.
|
|
- **Advertise-don't-copy pattern from the DS README.** DS nodes
|
|
advertise file existence (tag + hash) and serve the file only on
|
|
demand. The same pattern applies to chain proofs: a DS-integrated
|
|
trusty-postgres node should advertise `(chain_id, tip_hash)` and
|
|
serve the ProofBundle on pull, not push full chains through the
|
|
gossip layer. This shapes the read API: prefer iterator-based
|
|
proof export over "materialize the whole bundle in memory."
|
|
- **Key distribution is NOT DS's job** per the S14 SSH-paradigm
|
|
decision (`s14_decision_decouple_key_distribution_from_ds_handshake`).
|
|
DS session keys (X-Wing) and trusty verification keys (SipHash +
|
|
Ed25519 via #17 trusty-keydist) live on separate tracks. trusty-postgres
|
|
stores the SipHash keys in the `producer_identity` table (section
|
|
8); it does not participate in any DS handshake.
|
|
|
|
Out of scope for this memo but noted: a future `trusty-ds-postgres`
|
|
integration crate (separate from trusty-postgres) would implement
|
|
the `CapabilityHandler` trait from dspm and wire the Store trait to
|
|
DS marketplace messages. That crate is not trusty-postgres.
|
|
|
|
---
|
|
|
|
## 2. Scope: trait methods + higher-level Trusty methods
|
|
|
|
### Store trait surface
|
|
|
|
The 7 methods at `crates/core/trusty-store/src/lib.rs:22-48`:
|
|
|
|
- `last_row(entity_id)`
|
|
- `insert_row(&RowInsert)`
|
|
- `iter_chain(entity_id) -> Iterator<StoreResult<StoredRow>>`
|
|
- `get_tip(chain_id)`
|
|
- `get_tip_for_entity(entity_id)`
|
|
- `set_tip(&TipUpsert)`
|
|
- `kv_cross_check()`
|
|
- `with_txn(&mut dyn FnMut(&mut dyn Store) -> StoreResult<()>)`
|
|
|
|
All 7 must be implemented. `iter_chain` is the one with non-trivial
|
|
ergonomics in async-Postgres land -- see section 7 (with_txn) and
|
|
section 9 (concurrency).
|
|
|
|
### Higher-level Trusty methods (NOT in the Store trait today)
|
|
|
|
Per `s14_insight_store_trait_multi_backend_landscape.toml`, four
|
|
higher-level methods live in `trusty-sqlite::Trusty` rather than
|
|
behind the trait. They must be re-implemented in the Postgres
|
|
adapter:
|
|
|
|
- `append` / `append_signed` / `append_with_method` / `append_with_method_signed`
|
|
-- the orchestration around `compute_record` + `write_envelope` +
|
|
`append_hash_index` + `set_tip`. Source: `store.rs:152-283`.
|
|
- `append_batch(&[RowSpec]) -> Vec<AppendResult>` -- single-transaction
|
|
amortization. Source: `store.rs:528-553`.
|
|
- `verify(entity_id) -> VerifyReport` -- iterate `provenance_envelope`
|
|
in id-asc order, reconstruct `ChainLink`s, hand to
|
|
`trusty_core::verify_links`. Source: `store.rs:467-500`.
|
|
- `breaks_since`, `liveness_check`, `anchors_since`, `export_bundle`,
|
|
`history`, `integrity`, `anchor` -- the read/observability surface.
|
|
Source: `store.rs:502-836`.
|
|
|
|
Estimated 300-400 lines of mechanical work after the trait is
|
|
implemented, per the multi-backend tag.
|
|
|
|
### Out of scope for v1 of trusty-postgres
|
|
|
|
- `tk_*` binaries / APERC tooling -- no adapter-side hooks.
|
|
- Schema migrations between trusty-postgres versions. Document
|
|
the v1 schema as locked by analogy to the locked v1 byte-shape;
|
|
any v2 schema work is a future memo. (See section 11 cross-ref.)
|
|
- Connection pooling configuration as a public API. Use the driver's
|
|
pool with sane defaults; expose the pool object only if a downstream
|
|
user demands it.
|
|
|
|
---
|
|
|
|
## 3. Schema design
|
|
|
|
### Three-table layout, Postgres dialect
|
|
|
|
Mirror `crates/adapters/trusty-sqlite/src/schema.rs:1-49` one-to-one
|
|
in structure. The three-table layout is locked per CLAUDE.md "Don't
|
|
Touch": `provenance_envelope`, `hash_index`, `chain_tips`. The KV
|
|
cross-check (`integrity` method) depends on both `chain_tips` and
|
|
`hash_index` agreeing on `(entity_id, chain_id, current_tip_hash)`.
|
|
|
|
Postgres dialect translation:
|
|
|
|
- `INTEGER PRIMARY KEY AUTOINCREMENT` -> `BIGSERIAL PRIMARY KEY` or
|
|
`BIGINT GENERATED BY DEFAULT AS IDENTITY`. Use the latter; identity
|
|
columns are SQL-standard and easier to reset for tests.
|
|
- `TEXT` -> `TEXT` (Postgres TEXT is unbounded VARCHAR; same semantics).
|
|
- `INTEGER` for sequence_int -> `BIGINT`. Sequence numbers are i64
|
|
in the Rust types.
|
|
- Indexes: same set as SQLite. Add a partial index
|
|
`CREATE INDEX idx_pe_break ON provenance_envelope(entity_id, detected_at)
|
|
WHERE event_type = 'BREAK'` to accelerate `breaks_since`. The SQLite
|
|
version scans; Postgres can short-circuit with the partial index.
|
|
- `raw_payload TEXT` -> `raw_payload TEXT`. Do NOT use `JSONB`. The
|
|
payload is a canonical byte-stable JSON string that the verifier
|
|
reads back and recomputes hashes over. JSONB normalizes whitespace
|
|
and key order, which would silently break v1 byte-shape verification.
|
|
This is a v1-byte-shape-preservation invariant; document it inline
|
|
in the migration SQL.
|
|
|
|
### TimescaleDB hypertable on `received_at`
|
|
|
|
Decision: support TimescaleDB as an optional Cargo feature
|
|
(`features = ["timescale"]`), default off. When enabled, the migration
|
|
runs `SELECT create_hypertable('provenance_envelope', 'received_at',
|
|
chunk_time_interval => INTERVAL '7 days')`.
|
|
|
|
Reasoning:
|
|
|
|
- The IoT/sensor tier (per `project_trusty_role.md` in memory) is the
|
|
primary trusty-postgres adopter. Time-series query acceleration on
|
|
`received_at` is the right enterprise pitch.
|
|
- Hypertables do not change row identity, primary keys, or
|
|
read/write semantics from the chain's perspective. The hash chain
|
|
is independent of physical chunking.
|
|
- Default-off avoids forcing the TimescaleDB dependency on operators
|
|
who do not have it. The adapter must work on stock Postgres 14+.
|
|
- One constraint: `chain_tips` and `hash_index` are NOT hypertables.
|
|
They are small lookup tables; chunking by time would hurt, not help.
|
|
|
|
OPEN QUESTION: do we want a separate `received_at` column type
|
|
(`TIMESTAMPTZ`) versus the SQLite TEXT-ISO8601 string used today?
|
|
Recommendation: yes, store as `TIMESTAMPTZ` for query performance,
|
|
but expose as RFC3339 string at the Store trait boundary so the
|
|
trait is dialect-neutral. This is purely an internal storage choice
|
|
and does not affect chain bytes. Confirm with Eric.
|
|
|
|
---
|
|
|
|
## 4. Tri-state null (apply `learn-LCG-ARCH-03`)
|
|
|
|
The tag is unambiguous: "wherever absence of a value might be
|
|
legitimate, encode three states (PRESENT, ABSENT, UNKNOWN) explicitly."
|
|
SQL NULL alone conflates ABSENT and UNKNOWN.
|
|
|
|
Audit of nullable columns in the trusty schema:
|
|
|
|
| Column | NULL means today | Tri-state interpretation |
|
|
|---------------------------------|------------------------------------------------|---------------------------------------------------------------------|
|
|
| `provenance_envelope.row_hash` | "BREAK row, no hash applies" | ABSENT (BREAK rows structurally have no hash). Two-state is fine. |
|
|
| `provenance_envelope.anchor_hash` | same as row_hash | ABSENT. Two-state is fine. |
|
|
| `provenance_envelope.raw_payload` | same as row_hash | ABSENT. Two-state is fine. |
|
|
| `provenance_envelope.field_name`/`old_value`/`new_value` | "non-BREAK row, fields don't apply" | ABSENT. Two-state is fine. |
|
|
| `chain_tips.current_tip_hash` | TODAY ambiguous | NEEDS TRI-STATE. See below. |
|
|
| `chain_tips.chain_id` | "no chain bound to this entity yet" | UNKNOWN/ABSENT ambiguity. See below. |
|
|
|
|
The cold-start case is the one that bites:
|
|
|
|
> An OID-bound producer is provisioned (its OID is registered, its
|
|
> Ed25519 pubkey is in `producer_identity`) but it has not yet
|
|
> appended a single row.
|
|
|
|
In trusty-sqlite, this state is unrepresentable: `chain_tips` rows
|
|
are created on first append, so an unappended producer simply has
|
|
no row. That conflates "we know this OID exists and has appended
|
|
nothing" with "we have never heard of this OID." For an enterprise
|
|
PKI integration that pre-registers devices, this matters.
|
|
|
|
Decision: introduce explicit cold-start representation in the
|
|
trusty-postgres schema:
|
|
|
|
- `chain_tips.current_tip_hash`: encoded as `tip_hash TEXT` paired
|
|
with `tip_state TEXT NOT NULL CHECK (tip_state IN ('UNKNOWN',
|
|
'COLD', 'WARM'))`. `UNKNOWN` = pre-provisioned, never appended.
|
|
`COLD` = appended once but the chain was sealed by a break and a
|
|
new chain is pending. `WARM` = chain is live and tip_hash is the
|
|
current valid head.
|
|
- `chain_tips.chain_id`: NULL only when `tip_state = 'UNKNOWN'`.
|
|
Otherwise NOT NULL.
|
|
- New optional column `producer_oid TEXT` (see section 8).
|
|
|
|
Per `learn-LCG-ARCH-03`, prefer enum/check-constrained columns over
|
|
sentinel-value tricks. The pair `(tip_hash, tip_state)` makes every
|
|
`get_tip` result explicit about which of the three states it is in.
|
|
|
|
This forces a trait-level decision: should `TipRow` (defined at
|
|
`crates/core/trusty-store/src/lib.rs:60-66`) carry a tip_state field?
|
|
|
|
Recommendation: YES, additively. Add `tip_state: TipState` to
|
|
`TipRow` and `TipUpsert` with a default `WARM` for backward
|
|
compatibility with trusty-sqlite. trusty-sqlite would always return
|
|
`WARM` (its existing semantics) and trusty-postgres would return the
|
|
true state. Verify this does not break any existing trusty-sqlite
|
|
test before merging.
|
|
|
|
OPEN QUESTION: does this trait-level addition rise to the level of
|
|
needing v2 of the Store trait, or can it be additive on v1? Eric
|
|
to decide. Recommendation: additive with a default; this matches
|
|
the v2-payload-additive precedent in `project_trusty_v2_signing_design.md`.
|
|
|
|
---
|
|
|
|
## 5. Compaction strategy (apply `learn-LCG-ARCH-02`)
|
|
|
|
The tag is explicit: "chain format supports compaction snapshots
|
|
FROM v1. Archive boundaries are themselves attested. No retrofit
|
|
possible without breaking historical verifiability."
|
|
|
|
The append-only chain has no semantic notion of compaction today.
|
|
trusty-sqlite grows unbounded; the IoT-fleet pitch implies hundreds
|
|
of devices each appending hundreds of rows per day, which crosses
|
|
"too large to verify in a reasonable time" within months.
|
|
|
|
### What compaction means for an append-only hash chain
|
|
|
|
Three architectural options:
|
|
|
|
**Option A: snapshot tables.** Periodically materialize
|
|
`(chain_id, sequence_int_high_water, snapshot_root_hash, snapshot_payload)`
|
|
into a `chain_snapshot` table. The `snapshot_root_hash` is computed
|
|
canonically from the slice it covers (a Merkle root of all row_hashes
|
|
in the slice, or simpler: a SipHash over the concatenation, with
|
|
domain separation). The snapshot row is itself a valid chain envelope
|
|
of `event_type = 'SNAPSHOT'`. After a snapshot is committed, archived
|
|
slices can be moved to a cold-storage table or dropped, and verifiers
|
|
can resume mid-chain by trusting the snapshot.
|
|
|
|
**Option B: TCB manifest checkpoint.** Maintain a
|
|
`chain_checkpoint` table with `(chain_id, sequence_int, row_hash,
|
|
checkpoint_attestation)`. Verifiers can prune their verification
|
|
window to "from the most recent checkpoint forward" and trust earlier
|
|
state by checkpoint signature. Lighter weight than snapshots; does
|
|
not enable archival, only verification acceleration.
|
|
|
|
**Option C: Anchor checkpoints.** Repurpose existing per-row
|
|
`anchor_hash` as the compaction primitive. Periodically aggregate
|
|
anchors over a window into a single `aggregated_anchor` and publish
|
|
it externally (S3, public ledger, whatever). Verifiers spot-check
|
|
against the aggregated anchor. This is the lowest-cost option and
|
|
mirrors Certificate Transparency Merkle-tree consistency proofs.
|
|
|
|
### Decision
|
|
|
|
Ship Option B (`CHECKPOINT` envelope, verification-layer only) as
|
|
the v1 primitive in trusty-postgres. Reasoning:
|
|
|
|
- Issue #13 explicitly says "MUST NOT change v1 byte-shape.
|
|
Checkpoints are a verification-layer construct, not a payload
|
|
change." Option A (SNAPSHOT) adds a new event_type which,
|
|
while additive, is a semantic change to the chain contract and
|
|
requires a new trusty-core aggregation primitive -- that is a
|
|
larger design pass than v1 of trusty-postgres should attempt.
|
|
- Option B keeps the existing chain contract untouched. A
|
|
checkpoint is a signed (`chain_id, sequence_int, row_hash,
|
|
attestation)` tuple written to a separate `chain_checkpoint`
|
|
table. Verifiers can prune their verify window to "from the
|
|
most recent valid checkpoint forward." No new envelope type,
|
|
no new canonical bytes, no trusty-core change beyond exposing
|
|
the checkpointing API in a higher-level module.
|
|
- Archival (#5) and row-aggregation compaction (Option A) are
|
|
deferred to v2. Both build on top of the checkpoint primitive
|
|
once it is proven in production.
|
|
- Issues #5 and #13 resolve cleanly: #13 is the v1 feature; #5
|
|
is the v2 feature that uses checkpoints as archive boundaries.
|
|
- Terminology lock: the word `compaction` is retired from this
|
|
crate's vocabulary in favor of `checkpoint` (for v1) and
|
|
`archive` (for v2). No ambiguity.
|
|
|
|
Schema: add a `chain_checkpoint` table:
|
|
|
|
```
|
|
chain_checkpoint (
|
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
chain_id TEXT NOT NULL,
|
|
sequence_int BIGINT NOT NULL,
|
|
row_hash TEXT NOT NULL,
|
|
checkpoint_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
attested_by TEXT NOT NULL, -- OID or pubkey fingerprint
|
|
attestation_sig BYTEA NOT NULL, -- Ed25519 over (chain_id, sequence_int, row_hash)
|
|
UNIQUE (chain_id, sequence_int)
|
|
)
|
|
```
|
|
|
|
API on `Trusty`:
|
|
|
|
- `checkpoint(chain_id, attestation_key) -> CheckpointRow` -- walks
|
|
the chain to head, computes a fresh checkpoint at head, writes
|
|
the row.
|
|
- `verify_since_checkpoint(chain_id) -> VerifyReport` -- reads the
|
|
most recent valid checkpoint, iterates forward from there, same
|
|
report shape as `verify` today.
|
|
|
|
This is all adapter-level; no trusty-core change required for v1.
|
|
Archival and row-aggregation compaction become separate design
|
|
memos when the archival product case (#5) is ready to ship.
|
|
|
|
---
|
|
|
|
## 6. Revocation semantics (apply `learn-LCG-ARCH-06`)
|
|
|
|
The tag is blunt: "Every system that said 'we will figure revocation
|
|
out in v2' paid for it in v2 with interest." Existing trusty memory
|
|
treats revocation as application-layer. Re-examine for OID-anchored
|
|
deployment:
|
|
|
|
### The case under OID anchoring
|
|
|
|
A producer device has OID `1.3.6.1.4.1.<arc>.<leaf>` and an Ed25519
|
|
keypair. The chain is signed (v2 payload) per
|
|
`project_trusty_v2_signing_design.md`. Mid-stream, the device's key
|
|
is revoked -- compromised hardware, retired device, or operator
|
|
policy.
|
|
|
|
Three sub-cases:
|
|
|
|
**Sub-case (a): retroactive revocation of past rows.** Should rows
|
|
appended before the revocation timestamp still verify? If yes, the
|
|
chain remains valid up to the revocation point and is sealed there.
|
|
If no, the entire chain is poisoned by retroactive distrust.
|
|
|
|
**Sub-case (b): forward block on new appends.** After revocation,
|
|
any append signed by the revoked key must fail. This is structurally
|
|
the chain-break primitive: a revoked-key append is a break, sealed
|
|
chain ends, no new genesis under the same OID.
|
|
|
|
**Sub-case (c): key rotation.** Revoke old key, install new key,
|
|
continue chain under new key. Requires a chain-of-custody attestation
|
|
linking old-key-tip to new-key-genesis.
|
|
|
|
### Recommendation
|
|
|
|
**Adopt the Cert-Transparency stance: chains are append-only and
|
|
revocation is forward-only.** Sub-case (a) is rejected: past rows
|
|
remain verifiable forever. Sub-case (b) is the default behavior
|
|
(the existing chain-break primitive handles it for free if the
|
|
condition library checks signing-key validity; that condition needs
|
|
to exist). Sub-case (c) is a v2 feature involving a `KEY_ROTATION`
|
|
envelope and an attestation chain to the new key.
|
|
|
|
Schema: add a `producer_revocation` table:
|
|
|
|
```
|
|
producer_revocation (
|
|
producer_oid TEXT NOT NULL,
|
|
revoked_at TIMESTAMPTZ NOT NULL,
|
|
revoked_pubkey BYTEA NOT NULL,
|
|
reason TEXT NOT NULL,
|
|
attested_by_pubkey BYTEA, -- who authorized the revocation
|
|
revocation_envelope_id BIGINT -- FK to a provenance_envelope row
|
|
)
|
|
```
|
|
|
|
The revocation event is itself a chain envelope of
|
|
`event_type = 'REVOCATION'` so the act of revoking is part of the
|
|
auditable chain. This satisfies both the append-only invariant and
|
|
the auditability requirement.
|
|
|
|
Per the LCG-ARCH-06 runtime invariant: "revocation of an upstream
|
|
capability invalidates derived attestations within N blocks. Testable
|
|
via assert_invariants.py: insert edge, attest, revoke edge, verify
|
|
attestation fails on re-check." Translate to a trusty-postgres
|
|
integration test: append signed rows, write revocation envelope,
|
|
attempt new append signed by revoked key, assert it produces a
|
|
chain-break.
|
|
|
|
OPEN QUESTION: where does the condition that checks "is this signing
|
|
key currently revoked?" live? Options: (i) a new core-level Condition
|
|
in `trusty-core`, (ii) a Postgres-adapter-only Condition. Recommendation:
|
|
(i), so the semantics are uniform across backends. Schedule as part
|
|
of the trusty-core SNAPSHOT-primitive memo (section 5) -- same memo,
|
|
same set of additive trusty-core changes.
|
|
|
|
---
|
|
|
|
## 7. with_txn decision
|
|
|
|
Source of friction: `s14_learn_with_txn_async_friction.toml`. Sync
|
|
trait `fn with_txn(&mut self, f: &mut dyn FnMut(&mut dyn Store) ->
|
|
StoreResult<()>) -> StoreResult<()>` does not compose with async
|
|
Postgres drivers (sqlx, tokio-postgres) because closures cannot be
|
|
async without boxing a Future and dealing with lifetime/pinning.
|
|
|
|
Three options enumerated in the tag:
|
|
|
|
- **(a) `block_on` in adapter.** Keep the sync trait. The Postgres
|
|
adapter wraps a `tokio` runtime internally and `block_on`s every
|
|
call. Pros: zero changes to trusty-sqlite, zero changes to the
|
|
trait. Cons: the adapter's caller cannot be in an async context
|
|
without nested runtime issues; or we use the `synchronous-postgres`
|
|
driver (libpq directly via `postgres` crate) and skip async entirely.
|
|
|
|
- **(b) Boxed async closure.** Add an async variant of with_txn
|
|
to the trait:
|
|
`async fn with_txn_async(&mut self, f: BoxFuture<...>) -> StoreResult<()>`.
|
|
Pros: native async. Cons: trait object methods cannot be generic
|
|
AND cannot be async-fn-in-trait without macro support; ergonomics
|
|
are awful for callers.
|
|
|
|
- **(c) Split into begin / commit / rollback.** Drop the closure
|
|
pattern entirely. Trait gains `begin_txn`, `commit_txn`,
|
|
`rollback_txn`. Pros: composes with both sync and async. Cons:
|
|
callers lose RAII safety (forget to commit and the txn leaks);
|
|
trusty-sqlite must be updated too.
|
|
|
|
### Recommendation
|
|
|
|
**Option (a) -- block_on with the synchronous `postgres` crate (not
|
|
sqlx/tokio-postgres) -- for v1 of trusty-postgres.** Reasoning:
|
|
|
|
1. The synchronous `postgres` crate (built on `tokio-postgres` but
|
|
exposing a sync API) is mature, widely used, and avoids the
|
|
nested-runtime problem entirely. No `block_on` needed -- the
|
|
driver is sync from the user's perspective.
|
|
2. The trait stays unchanged. trusty-sqlite stays unchanged. Other
|
|
adapters (DuckDB, ScyllaDB) can make their own sync-vs-async
|
|
choice without us locking the trait shape.
|
|
3. Async-Postgres consumers who want native `tokio` integration can
|
|
wait for a v2 trait or implement Option (c) themselves; this is
|
|
not a v1 requirement for the IoT/compliance buyer pitch.
|
|
|
|
**However**, document the trait friction explicitly in
|
|
trusty-postgres README so the next person who tries to wire it into
|
|
an async axum/tonic server is not surprised. Add a worked example
|
|
of using `tokio::task::spawn_blocking` to bridge.
|
|
|
|
**Defer Option (c) to a future trait-v2 pass.** When we get serious
|
|
about ScyllaDB or another genuinely-async backend, revisit. At that
|
|
point all adapters get updated together. Eric to decide whether to
|
|
formally schedule that pass; not blocking trusty-postgres v1.
|
|
|
|
OPEN QUESTION: confirm the `postgres` (sync wrapper) crate is the
|
|
right choice over `rust-postgres` direct or `diesel`. Recommendation:
|
|
`postgres` crate; it is the de facto standard sync Postgres client
|
|
for Rust and has the same maintainer as `tokio-postgres`.
|
|
|
|
---
|
|
|
|
## 8. OID + chain_id binding (apply `s14_insight_oid_anchored_chain_identity`)
|
|
|
|
The OID-anchored model is the entire commercial reason this crate
|
|
exists. Get this right at v1.
|
|
|
|
### The binding
|
|
|
|
Per the insight tag, the model is:
|
|
|
|
- An OID (e.g. `1.3.6.1.4.1.<vendor-arc>.<device-leaf>`) identifies
|
|
a producer (device, sensor, agent).
|
|
- That OID is the canonical chain identity. Either chain_id IS the
|
|
OID, or chain_id is derivable from the OID + a generation counter.
|
|
- An Ed25519 pubkey is bound to the OID via a known_producers TOFU
|
|
registry (per the SSH-paradigm tag).
|
|
|
|
The trusty-sqlite chain_id format today is `chain_<entity_id>_<6-digit-gen>`
|
|
(see `make_chain_id`, used at `store.rs:299` and parsed at `store.rs:965-975`).
|
|
Eric: this format is fine for SQLite where entity_id is opaque, but
|
|
for OID-anchored Postgres deployments we want OID legibility.
|
|
|
|
### Schema
|
|
|
|
Add a `producer_identity` table, separate from the three locked tables:
|
|
|
|
```
|
|
producer_identity (
|
|
producer_oid TEXT PRIMARY KEY,
|
|
ed25519_pubkey BYTEA NOT NULL,
|
|
siphash_key BYTEA NOT NULL, -- derived per SSH-paradigm tag
|
|
registered_at TIMESTAMPTZ NOT NULL,
|
|
display_name TEXT,
|
|
metadata JSONB -- ok to use JSONB here:
|
|
-- not part of chain bytes
|
|
)
|
|
```
|
|
|
|
And add a `producer_oid TEXT` column to `chain_tips`, foreign-keyed
|
|
to `producer_identity.producer_oid` when present (nullable for backward
|
|
compat with non-OID deployments).
|
|
|
|
Decision: do NOT change the chain_id format string. The format
|
|
`chain_<entity_id>_<6-digit-gen>` is locked by trusty-core's
|
|
`make_chain_id` (at `crates/core/trusty-core/src/chain.rs:100`).
|
|
Note: `parse_generation` currently lives in the SQLite adapter at
|
|
`crates/adapters/trusty-sqlite/src/store.rs:965`; it should be
|
|
promoted to trusty-core as a pre-requisite of this work so
|
|
trusty-postgres does not copy-paste. Additive change; no v1
|
|
byte-shape impact.
|
|
|
|
Instead:
|
|
|
|
- For OID deployments, set `entity_id` to the OID string.
|
|
`make_chain_id` will produce `chain_1.3.6.1.4.1.<arc>.<leaf>_000001`,
|
|
which is human-readable and OID-legible.
|
|
- The `producer_oid` column on `chain_tips` denormalizes the OID
|
|
for indexing/joins to `producer_identity`.
|
|
|
|
### Reconciliation with Gitea #14
|
|
|
|
Issue #14 proposes `chain_id = H(domain_sep || pubkey || nonce)`.
|
|
Taken literally this contradicts the decision above. Reading the
|
|
full issue body resolves the conflict: #14 is explicitly additive
|
|
-- "Existing v1 chains without attribution remain verifiable."
|
|
The two models coexist:
|
|
|
|
- **chain_id string** stays as `make_chain_id(entity_id, gen)` for
|
|
human legibility, log grep-ability, and OID integration.
|
|
- **Cryptographic binding** moves into (a) the v2/v3 genesis-row
|
|
signature (the Ed25519 signature over the signing-view commits
|
|
to chain_id via the row_hash), plus (b) the
|
|
`producer_identity(producer_oid, ed25519_pubkey, ...)` row that
|
|
proves which key is allowed to sign for this OID.
|
|
|
|
A rival chain with the same chain_id string cannot produce a valid
|
|
genesis signature without the private key, so the "malicious party
|
|
presents a rival chain" threat in #14 is defended against by the
|
|
signature chain, not by the id format. #14 becomes "require genesis
|
|
sig + producer_identity lookup before trusting any append," not
|
|
"change the chain_id derivation." No trusty-core change to
|
|
`make_chain_id`.
|
|
|
|
The `producer_identity` table is the local cache equivalent of an
|
|
SSH `known_hosts` file (per the SSH-paradigm tag) -- not a CA, just
|
|
a TOFU registry. Whether OIDs cross-reference to X.509 cert chains
|
|
is application-layer and out of scope here.
|
|
|
|
OPEN QUESTION: should the `producer_identity` table be optional
|
|
(feature-gated) or always created? Recommendation: always created,
|
|
but rows are optional. Adapters that do not use OID-anchoring simply
|
|
leave it empty. Cost is one empty table; benefit is one schema for
|
|
all deployments.
|
|
|
|
OPEN QUESTION: OID validation -- enforce the dotted-numeric format
|
|
at the column level (CHECK constraint) or accept arbitrary strings?
|
|
Recommendation: accept arbitrary strings. trusty-postgres should not
|
|
become an OID validator; that is the producer's responsibility. We
|
|
can add a CHECK later if a misuse pattern emerges.
|
|
|
|
---
|
|
|
|
## 9. Concurrency model
|
|
|
|
### The core difference from SQLite
|
|
|
|
trusty-sqlite is single-writer by SQLite's nature (WAL mode plus
|
|
`BEGIN IMMEDIATE`, see `store.rs:535`). The chain primitive's
|
|
correctness depends on one fundamental invariant: between reading
|
|
the tip and writing the next row, no other writer mutates the tip.
|
|
SQLite gives us this trivially.
|
|
|
|
Postgres allows multi-writer. Two clients calling `append()` for
|
|
the same `entity_id` concurrently can both:
|
|
1. Read the same tip.
|
|
2. Compute next sequence = tip.sequence + 1.
|
|
3. Insert two rows with the same sequence and the same prev_hash.
|
|
|
|
Result: chain integrity violation. Both rows verify in isolation
|
|
(both have correct prev_hash) but the chain has forked. Verification
|
|
will eventually catch it (sequence collision in iter_chain) but
|
|
silent damage is done.
|
|
|
|
### Decision: per-entity row-level lock at append time
|
|
|
|
Use Postgres advisory locks keyed on entity_id:
|
|
|
|
```
|
|
SELECT pg_advisory_xact_lock(hashtext($1)); -- $1 = entity_id
|
|
```
|
|
|
|
Inside the transaction, take an advisory lock derived from
|
|
`entity_id` (via `hashtext` or a dedicated 64-bit hash). The lock
|
|
is held for the duration of the transaction and released on commit
|
|
or rollback. Two appenders for the same entity serialize; appenders
|
|
for different entities run concurrently.
|
|
|
|
Rationale:
|
|
|
|
- Advisory locks are cheap (no row materialization), session-scoped
|
|
or transaction-scoped, and Postgres-native.
|
|
- They serialize exactly the operations that need serialization: the
|
|
read-tip / compute / write-row / update-tip cycle for a single
|
|
entity.
|
|
- Different entities are independent; no global lock, so multi-entity
|
|
fleets scale.
|
|
|
|
Alternative considered: `SELECT ... FOR UPDATE` on `chain_tips` rows.
|
|
This works but materializes a row lock on every append even when no
|
|
contention exists. Advisory locks are more direct.
|
|
|
|
Alternative considered: optimistic concurrency with a retry loop on
|
|
sequence-collision unique-violation. Possible but complicates the
|
|
adapter (retry loops, error mapping). Defer.
|
|
|
|
### Test invariants
|
|
|
|
Add a concurrent-append test: spawn N threads each appending K rows
|
|
to the same entity_id. Assert: total rows = N*K, sequences are
|
|
0..N*K-1 with no gaps, `verify` passes, `kv_cross_check` passes.
|
|
|
|
OPEN QUESTION: should the advisory lock be transaction-scoped
|
|
(`pg_advisory_xact_lock`) or session-scoped
|
|
(`pg_advisory_lock` + explicit unlock)? Recommendation: transaction-scoped.
|
|
Releases automatically on commit/rollback; no leak risk.
|
|
|
|
---
|
|
|
|
## 10. Migration story: trusty-sqlite -> trusty-postgres
|
|
|
|
The product use case: a customer runs trusty-sqlite at the edge for a
|
|
year, then promotes their archive to trusty-postgres for long-term
|
|
audit. This must work without recomputing chain hashes.
|
|
|
|
### Byte-shape preservation
|
|
|
|
The locked v1 byte-shape (see CLAUDE.md "Don't Touch") guarantees
|
|
that `raw_payload` strings are byte-identical regardless of backend.
|
|
Migration is a row-by-row copy with no recomputation:
|
|
|
|
1. SELECT all rows from sqlite `provenance_envelope` ordered by `id`.
|
|
2. INSERT into postgres `provenance_envelope` preserving every column
|
|
verbatim. The `id` column re-issues from the postgres identity
|
|
sequence; that is fine because chain semantics depend on
|
|
sequence_int + prev_hash + chain_id, not on the surrogate id.
|
|
3. SELECT all rows from sqlite `hash_index`, INSERT into postgres
|
|
`hash_index` verbatim.
|
|
4. SELECT all rows from sqlite `chain_tips`, INSERT into postgres
|
|
`chain_tips` verbatim. Set `tip_state = 'WARM'` for all migrated
|
|
rows; the migration tool cannot reconstruct cold/unknown state.
|
|
5. Run `verify` for every entity_id on the postgres side; assert
|
|
reports match the sqlite side row-for-row.
|
|
6. Run `kv_cross_check` on the postgres side; assert OK.
|
|
|
|
### Anchor hashes
|
|
|
|
Anchor hashes are NOT recomputed -- they are byte-identical between
|
|
backends because they are derived from the locked v1 primitives.
|
|
Copy them verbatim. If the post-migration verify pass fails on
|
|
anchor mismatch, the bug is in the migration tool, not in the chain.
|
|
|
|
### Implementation
|
|
|
|
Ship a `trusty-migrate` binary in the trusty-postgres crate:
|
|
|
|
```
|
|
trusty-migrate \
|
|
--from sqlite://path/to/file.db \
|
|
--to postgres://user:pass@host/db \
|
|
--verify
|
|
```
|
|
|
|
`--verify` runs the full verify pass on both sides post-migration
|
|
and asserts byte-identity. Exit non-zero on any mismatch.
|
|
|
|
OPEN QUESTION: do we want bidirectional migration (postgres ->
|
|
sqlite for offline audit)? Recommendation: yes, but defer to a v2
|
|
of the migration tool. v1 ships sqlite -> postgres only. The
|
|
inverse direction has the same byte-shape guarantee so it is
|
|
mechanical to add later.
|
|
|
|
---
|
|
|
|
## 11. Testing strategy
|
|
|
|
The bar to merge trusty-postgres v1:
|
|
|
|
### Required test suites
|
|
|
|
1. **All trusty-store testing-feature tests pass.** The Store trait
|
|
ships a conformance suite under
|
|
`crates/core/trusty-store/src/testing.rs` (per the existing
|
|
`#[cfg(feature = "testing")]` module, see
|
|
`crates/core/trusty-store/src/lib.rs:15-16`). The v1 conformance
|
|
suite is 6 tests, enumerated explicitly:
|
|
- `insert_and_read_back` -- insert a row; `last_row` returns it.
|
|
- `iter_chain_ordered` -- insert 3 rows; iterator yields them
|
|
in sequence order with no gaps.
|
|
- `tip_roundtrip` -- `set_tip` + `get_tip_for_entity` returns
|
|
same row; `get_tip(chain_id)` agrees.
|
|
- `kv_cross_check_clean` -- fresh insert produces OK report.
|
|
- `with_txn_commit` -- inserts inside `with_txn` closure persist.
|
|
- `with_txn_rollback` -- `Err` return from closure rolls back.
|
|
|
|
trusty-postgres must pass all 6 identically to trusty-sqlite
|
|
and trusty-memory.
|
|
|
|
2. **v1 byte-shape conformance.** Add a test that appends N rows
|
|
via trusty-postgres, exports a `ProofBundle`, and asserts every
|
|
row_hash and anchor_hash matches the same sequence appended via
|
|
trusty-sqlite with the same SecretKey, same payload, same
|
|
timestamps. This is the runtime version of
|
|
`s5_invariant_v1_byte_shape_preserved` for the Postgres adapter.
|
|
|
|
3. **Multi-writer concurrency.** Per section 9: N threads, K rows
|
|
each, single entity. Assert no sequence gaps, no duplicate
|
|
sequences, verify passes.
|
|
|
|
4. **Cold-start tri-state.** Provision a producer_identity row with
|
|
no chain_tips row; assert `get_tip_for_entity` returns
|
|
`tip_state = UNKNOWN`. After first append, assert
|
|
`tip_state = WARM`.
|
|
|
|
5. **Revocation flow.** Append signed rows, write a REVOCATION
|
|
envelope, attempt append signed by revoked key, assert
|
|
chain-break.
|
|
|
|
6. **Compaction round-trip.** Append N rows, run `compact()` at
|
|
high_water = N/2, assert verifier can still verify the full chain
|
|
(using the snapshot envelope to skip archived rows).
|
|
|
|
7. **Migration tool round-trip.** Append M rows to sqlite, migrate
|
|
to postgres, run verify on both; assert reports identical.
|
|
|
|
8. **TimescaleDB feature gate.** With `--features timescale`, the
|
|
migration runs `create_hypertable` and inserts/queries succeed.
|
|
Without the feature, the same tests pass on stock Postgres.
|
|
|
|
### Test infrastructure
|
|
|
|
Use `testcontainers-rs` or equivalent to spin a Postgres container
|
|
per test run. Do not depend on a developer-local Postgres install;
|
|
CI must be hermetic.
|
|
|
|
OPEN QUESTION: should the conformance suite in
|
|
`crates/core/trusty-store/src/testing.rs` be expanded with the
|
|
tri-state, concurrency, and OID tests so all backends run them?
|
|
Recommendation: yes, but in a follow-up trusty-store PR. Initially
|
|
add them inline in trusty-postgres; promote to the conformance suite
|
|
once stable and once trusty-sqlite is updated to support tri-state
|
|
TipState (default WARM).
|
|
|
|
---
|
|
|
|
## 11b. Cross-cutting policy decisions (added post-research pass)
|
|
|
|
Decisions on four policies that applied across multiple sections
|
|
and were surfaced in the S14-addendum research pass. These are
|
|
the memo's current position; any of them can be re-opened but all
|
|
four are in scope as implementation-ready defaults.
|
|
|
|
### Payload version accept policy
|
|
|
|
trusty-postgres accepts reads of v1, v2, and v3 payloads (dispatch
|
|
already lives in trusty-core via `row_hash_for_version` and
|
|
`extract_payload_version`, confirmed at
|
|
`crates/core/trusty-core/src/chain.rs:52,211`). For new appends
|
|
the adapter writes v3 only. This guarantees:
|
|
|
|
- The `trusty-migrate sqlite -> postgres` tool copies v1/v2 rows
|
|
byte-verbatim; no recomputation, no upgrade of existing bytes.
|
|
- All new rows benefit from domain separation.
|
|
- `compute_record_signed` is v2-only today (handoff acknowledged
|
|
gap). trusty-postgres signed-append uses v2 bytes until
|
|
`compute_record_signed_multi` or a v3-signed path lands in
|
|
trusty-core -- tracked as a pre-req of #16 (patch-policy multi-sig).
|
|
|
|
### Schema versioning discipline
|
|
|
|
Schema migrations are planned, not forbidden. A `schema_meta` table
|
|
ships in v1 carrying `(key TEXT PRIMARY KEY, value TEXT)` with a
|
|
single row `('schema_version', '1')`. Any future schema change
|
|
ships with:
|
|
|
|
- A new version number written to `schema_meta`.
|
|
- An idempotent forward migration tested end-to-end.
|
|
- A design memo at `docs/trusty-postgres-schema-vN.md` documenting
|
|
the change.
|
|
|
|
Same rigor as payload v1/v2/v3 work. Do not pretend the schema is
|
|
locked; pretending is how drift happens.
|
|
|
|
### External anchor publish
|
|
|
|
App-level, not adapter-level. trusty-postgres exposes two read
|
|
APIs that downstream crates use to build publish pipelines:
|
|
|
|
- `stream_anchors_since(chain_id, sequence) -> impl Iterator<AnchorRow>`
|
|
- `export_bundle_since(chain_id, sequence) -> ProofBundle`
|
|
|
|
No S3, Git, CT-log, or DS-transport dependency in the adapter.
|
|
Retention/transport pipeline (#5) is a separate crate (likely
|
|
`trusty-archive` or the DS integration crate) that consumes these
|
|
iterators. Keeps trusty-postgres's TCB and dependency graph thin.
|
|
|
|
### Observability
|
|
|
|
`tracing` spans only. Every public method on the adapter emits a
|
|
span with `chain_id`, `entity_id`, and operation name. No
|
|
prometheus/opentelemetry crate dependency -- consumers plug in
|
|
any `tracing_subscriber` exporter they want (`tracing-opentelemetry`,
|
|
`tracing-prometheus-exporter`, etc.) at their layer. Zero-cost
|
|
when no subscriber is attached.
|
|
|
|
Span namespacing: `trusty_postgres::append`, `trusty_postgres::verify`,
|
|
`trusty_postgres::with_txn`, `trusty_postgres::checkpoint`. Include
|
|
advisory-lock wait time as a span event so lock contention is
|
|
visible to the subscriber.
|
|
|
|
---
|
|
|
|
## 11c. Multi-tenant federated deployment (real target)
|
|
|
|
The original memo framed trusty-postgres as a generic mid-scale-cloud
|
|
Store adapter. The actual product goal is tighter: a **federated shared
|
|
knowledge base** spanning up to ~50 users, each with many agents writing
|
|
concurrently, glued between user nodes by Distant Star, with the
|
|
existing kb-mesh content (pins, synthesis artifacts, architectural
|
|
decisions) materialized as trusty chains. This section captures the
|
|
deployment shape implied by that goal; it supersedes no prior decision
|
|
but adds concrete structure where the original memo was generic.
|
|
|
|
### Deployment shape
|
|
|
|
- **Per-user Postgres schema.** Each user gets a Postgres schema
|
|
(namespace) named for their OID arc leaf, e.g. `usr_eric`,
|
|
`usr_partner`, `usr_agent03`. Inside that schema the three-table
|
|
layout (`provenance_envelope`, `hash_index`, `chain_tips`) is
|
|
replicated. Schema-per-user gives hard isolation (an agent cannot
|
|
accidentally write to another user's rows), per-schema index
|
|
locality, and straightforward per-user backup/archive.
|
|
- **Cross-user schema in `public`.** One schema-wide table set:
|
|
`public.producer_identity` (OID -> Ed25519 pubkey + SipHash key +
|
|
metadata, the known_producers registry), `public.schema_meta`
|
|
(global schema version), `public.received_bundles` (inbound
|
|
DS-delivered bundles awaiting or completed verification).
|
|
- **Cross-user read view.** `public.all_provenance_envelope AS SELECT
|
|
... FROM usr_eric.provenance_envelope UNION ALL ... FROM
|
|
usr_partner.provenance_envelope ...` regenerated whenever a user
|
|
is provisioned or deprovisioned. Read-side queries spanning all
|
|
users (e.g. "every pin tagged trusty across the mesh") go through
|
|
this view; write-side never touches it.
|
|
- **One chain per (user, agent, topic) tuple.** `entity_id` encodes
|
|
`<user_oid_leaf>/<agent_oid_leaf>/<topic>`. `make_chain_id` on
|
|
top of that produces `chain_<user>/<agent>/<topic>_000001`.
|
|
Topic is caller-chosen (session id, KB pin topic, sensor stream
|
|
name, etc.).
|
|
|
|
### Ownership and authorization
|
|
|
|
- Every agent has its own Ed25519 keypair and an OID leaf under its
|
|
user's arc. `producer_identity` rows bind (OID -> pubkey).
|
|
- Append is rejected at the adapter if the signing key does not
|
|
match the `producer_identity` row for the target chain's OID.
|
|
This turns the two-party "one writer per chain" rule into a
|
|
general N-party invariant enforced at write time.
|
|
- Agents provisioned/deprovisioned by a user-level operator flow
|
|
(out of scope for this crate; in scope for a future
|
|
`trusty-admin` tool).
|
|
|
|
### Concurrency at scale
|
|
|
|
- Advisory lock per `entity_id` inside each user's schema.
|
|
`hashtext(entity_id)` already makes different entities contention-
|
|
free; per-entity serialization matters only when two of one user's
|
|
agents race the same chain.
|
|
- Connection pool (pgbouncer or equivalent) required in any
|
|
deployment exceeding ~100 concurrent agents. Memo-level
|
|
recommendation: transaction-pooling mode with a pool of
|
|
`max_connections / N_users` per user's app tier.
|
|
- Postgres's own MVCC handles the 50-user + multi-agent load
|
|
comfortably. The bottleneck is per-entity advisory locks, not
|
|
the database. Measured scaling will come from real deployment;
|
|
design-time assumption is that the chain-break rate stays O(errors)
|
|
not O(normal appends).
|
|
|
|
### Federation via Distant Star
|
|
|
|
- Each user runs their own Postgres + DS node. Writes land locally
|
|
under the user's schema.
|
|
- DS gossips ProofBundles peer-to-peer. Advertise-don't-copy: a DS
|
|
advertisement carries `(chain_id, tip_hash, size_hint)`; bundles
|
|
are pulled on demand.
|
|
- Inbound bundles land in `public.received_bundles` with:
|
|
`bundle_id`, `source_producer_oid`, `chain_id`, `tip_hash`,
|
|
`received_at`, `verify_status`, `verify_detail`, `bundle_blob`
|
|
(bytea). Verification runs against `producer_identity` (did the
|
|
claimed OID sign this?); failed bundles stay with `verify_status
|
|
= 'REJECTED'` and a reason -- not silently dropped, not replayed.
|
|
- A user's view of "what partner has" is a query against
|
|
`received_bundles WHERE source_producer_oid IN (partner's OIDs)`.
|
|
Historical verification remains local: even if the partner goes
|
|
offline or leaves the federation, bundles already ingested are
|
|
still cryptographically verifiable.
|
|
|
|
### kb-mesh migration
|
|
|
|
The existing kb-mesh primitive (ephemeral pins + promoted patterns)
|
|
maps cleanly:
|
|
|
|
- Each pin becomes a chain row with `event_type = 'PIN'` and payload
|
|
data carrying `topic`, `turns`, `source_session`, `synthesis_level`,
|
|
pin body.
|
|
- `action = 'create' | 'update' | 'overwrite' | 'close' | 'promoted'`
|
|
from INDEX.jsonl becomes `extraction_method` or a structured field
|
|
inside `data`.
|
|
- Per-repo kb-mesh pins roll into a per-user chain named something
|
|
like `usr_<repo>/agent_<session>/kb-mesh-pins`. INDEX.jsonl becomes
|
|
redundant (`SELECT ... FROM ...provenance_envelope WHERE event_type
|
|
= 'PIN'` replaces it).
|
|
- Promoted patterns (ephemeral pin -> durable `.kb/patterns/` file)
|
|
become CHECKPOINT envelopes over the pin chain + a supplementary
|
|
write to a pattern chain.
|
|
|
|
A dedicated `trusty-kb-mesh` (or `trusty-kb`) crate owns the mapping;
|
|
trusty-postgres is the storage. Gitea issue to follow.
|
|
|
|
### Changes to earlier sections this implies
|
|
|
|
- §1 add non-goal: trusty-postgres is not a tenant manager; user
|
|
provisioning and schema creation live in a future `trusty-admin`
|
|
crate or operator script.
|
|
- §4 (tri-state null): `tip_state` is per-user; cold-start state
|
|
is per-agent per-chain, not global.
|
|
- §8 (OID + chain_id): chain_id convention tightened -- `<user>/<agent>/<topic>`
|
|
inside the template. `entity_id` encodes the same. Unchanged at
|
|
the byte level; just a convention on the strings.
|
|
- §9 (concurrency): advisory lock is per-entity within a schema,
|
|
not global. Hash collision risk on `hashtext(entity_id)` across
|
|
schemas is irrelevant because different schemas are different
|
|
critical sections anyway.
|
|
- §10 (migration): `trusty-migrate sqlite -> postgres` must now
|
|
specify a target schema (or default to `public` for single-user
|
|
deployments).
|
|
|
|
### Open questions specific to the multi-tenant shape
|
|
|
|
- How do we test 50-user + many-agent concurrency before deployment?
|
|
Probably a load-gen harness that spins N schemas + K async appenders
|
|
per schema. Out of scope for trusty-postgres v1; in scope for a
|
|
`trusty-load` test binary later.
|
|
- How do schema migrations roll out across N user schemas without
|
|
blocking active writers? Partial answer: ALTER runs per-schema in
|
|
sequence, advisory-locked against that schema's writers. Full
|
|
answer needs a real migration tool.
|
|
- Bundle ingestion rate limits per source producer? Probably yes,
|
|
to prevent a rogue peer from flooding; app-level concern, not
|
|
adapter.
|
|
|
|
---
|
|
|
|
## 12. Open questions and explicit deferrals
|
|
|
|
Items needing Eric's input before implementation. Items marked
|
|
RESOLVED were decided in the post-research pass; strike-through
|
|
anything that later changes.
|
|
|
|
1. **RESOLVED: TipState additive trait change (S16).** Approved
|
|
as v1-compatible additive change with default WARM. Precedent:
|
|
v2 signing (`project_trusty_v2_signing_design`) added fields to
|
|
`PayloadFields` and a new top-level `compute_record_signed`
|
|
without breaking v1 callers; the same additive-default pattern
|
|
applies to trait fields. `TipState` is store metadata, not
|
|
chain-payload bytes, so v1 byte-shape is unaffected by
|
|
construction.
|
|
|
|
2. **RESOLVED: Compaction primitive scope** (section 5). Decision:
|
|
ship `CHECKPOINT` (verification-layer only) for v1; archival and
|
|
row-aggregation compaction are v2. No trusty-core change. Retired
|
|
the word "compaction" from this crate's vocabulary.
|
|
|
|
3. **RESOLVED: Revocation Condition placement (S16).** trusty-core.
|
|
Uniform across backends prevents two adapters from diverging on
|
|
what counts as revoked, which would silently fork chain
|
|
verification semantics. The cost (a small hash-set lookup in
|
|
the core verify path) is negligible vs. the consistency win.
|
|
|
|
4. **RESOLVED: Sync vs async driver (S16).** Sync `postgres` crate
|
|
for v1 confirmed. Trait stays unchanged; `with_txn` is already
|
|
validated. Defer Option (c) trait-v2 split until ScyllaDB or
|
|
similar makes async unavoidable.
|
|
|
|
5. **RESOLVED: OID validation (S16).** Enforce dotted-numeric
|
|
format at column level via `CHECK` constraint
|
|
(`chain_id ~ '^[0-9]+(\\.[0-9]+)*$'` or equivalent on the
|
|
producer_oid column). OID format is the trust spine of
|
|
federation; arbitrary strings invite ad-hoc IDs that defeat
|
|
the cryptographic identity model. Fail fast at the boundary.
|
|
|
|
6. **RESOLVED: TimescaleDB column type (S16).** Use `TIMESTAMPTZ`
|
|
internally; trait surface stays RFC3339 strings. Indexing on
|
|
`TEXT` timestamps is bad; conversion is transparent at the
|
|
adapter boundary; conformance test enforces the RFC3339
|
|
round-trip. No byte-shape impact (timestamps live in payload
|
|
data, not envelope columns subject to v1 lock).
|
|
|
|
7. **RESOLVED: Bidirectional migration (S16).** v1 ships sqlite ->
|
|
postgres only. postgres -> sqlite is deferred to v2 (or never).
|
|
Federated multi-tenant is the production target; nobody
|
|
migrates back from federation to a single-file edge backend.
|
|
|
|
8. **RESOLVED: Conformance suite expansion (S16).** Promote new
|
|
tests into `trusty-store::testing` AFTER the first stable
|
|
trusty-postgres release. Keeps churn local during development;
|
|
promotion gated on proven stability.
|
|
|
|
9. **RESOLVED: Repo location (S16).** Separate Gitea repo
|
|
`mm-trusty/trusty-postgres` (already pushed via subtree split,
|
|
commit 427c7a7), depending on `trusty-core` and `trusty-store`
|
|
via path or git dep. Matches the workspace shape already in
|
|
flight; keeps trusty-core/sqlite TCB minimal; users pick
|
|
path/git/crates.io per deployment.
|
|
|
|
10. **RESOLVED: TCB manifest (S16).** trusty-postgres README
|
|
page-one section. Matches `learn-LCG-ARCH-13` recommendation;
|
|
one place, explicit, no separate doc to drift.
|
|
|
|
11. **RESOLVED: chain_id derivation** (section 8). Decision: keep
|
|
`make_chain_id` string template; move cryptographic binding
|
|
into genesis-row signature + `producer_identity` table. #14
|
|
becomes "require genesis sig + identity lookup," additive only,
|
|
no trusty-core change to `make_chain_id`.
|
|
|
|
12. **RESOLVED: payload version accept policy** (section 11b).
|
|
Decision: accept v1/v2/v3 reads; new appends are v3.
|
|
|
|
13. **RESOLVED: schema versioning discipline** (section 11b).
|
|
Decision: plan migrations; ship `schema_meta` table in v1;
|
|
same rigor as payload-version work.
|
|
|
|
14. **RESOLVED: external anchor publish** (section 11b). Decision:
|
|
app-level. Adapter exposes `stream_anchors_since` and
|
|
`export_bundle_since` read APIs; publishing lives in a
|
|
downstream crate.
|
|
|
|
15. **RESOLVED: observability** (section 11b). Decision: `tracing`
|
|
spans only, no prometheus/otel dependency. Consumers plug in
|
|
any subscriber layer.
|
|
|
|
16. **RESOLVED: `parse_generation` location (S16).** Promote to
|
|
`trusty-core` as part of the Tier 1 refactor
|
|
(`s14_insight_general_library_improvements`). Pure string
|
|
parser, no byte-shape involvement. Every future backend
|
|
would re-copy-paste otherwise.
|
|
|
|
17. **RESOLVED: `compute_record_signed` v3 gap (S16).**
|
|
`compute_record_signed` now version-dispatches by
|
|
`fields.payload_version`: v1/v2 callers stay byte-identical
|
|
(v1 upgrades to v2 per S5 behavior), v3 callers get the
|
|
domain-tagged signed path via `row_hash_for_version` /
|
|
`anchor_hash_for_version`. Two new tests:
|
|
`v3_signed_record_verifies` and
|
|
`v3_tampered_signature_fails_verify`. 50 trusty-core tests
|
|
pass (was 48). HARD BLOCKER lifted; §13 P1/P4/P7 unblocked.
|
|
`compute_record_signed_multi` (tied to #16) remains a
|
|
separate follow-on for N-signer multi-sig.
|
|
|
|
18. **First-class edges (Graphiti P6, deferred from §13).** Cross-
|
|
user / cross-agent provenance today rides on the chain_id
|
|
string template (`<user>/<agent>/<topic>`). Graphiti models
|
|
edges as their own chained relation with endpoint refs +
|
|
source-episode pointer, which would make federation traces
|
|
queryable. Conflicts mildly with the §8 chain-id-as-template
|
|
legibility decision and adds a parallel structure. Needs its
|
|
own scoping memo before any code; not adopted in §13.
|
|
|
|
---
|
|
|
|
## 13. Patterns lifted from Cozo + Graphiti
|
|
|
|
Cross-reference of design patterns from CozoDB (embedded Datalog
|
|
graph DB; pure Rust; HNSW + per-relation time-travel) and
|
|
Graphiti (Zep's temporal context graph; episode-based
|
|
provenance) against the trusty-postgres KB. Each pattern is
|
|
mapped to its existing anchor in the design memo or APERC tag
|
|
substrate. **All eight patterns are additive to v1 byte-shape
|
|
-- no change to `compute_record`, `build_payload`, or the
|
|
three locked tables' chained columns.**
|
|
|
|
### 13.1 Substrate
|
|
|
|
**P1 -- Stored-relation registry (Cozo).** A `chained_relations`
|
|
meta table where each app-level relation opts in to chaining
|
|
via a row + trigger calling `trusty_append`. Existing anchor:
|
|
`s15_insight_kb_surface_taxonomy_for_trusty_kb_adapter`
|
|
mandates "four clean mapping functions" (policy / decisions /
|
|
tags / pins) and "one entry point per concern." A registry
|
|
formalizes that mandate -- the four kb-mesh surfaces become
|
|
registry rows instead of bespoke crate-level glue. Slots into
|
|
§3 schema and §11c kb-mesh chain mapping.
|
|
|
|
**P5 -- Episode as canonical append unit (Graphiti).**
|
|
`provenance_envelope` already is the episode; `event_type`
|
|
already names the kind (PIN / DECISION / POLICY_ROW per
|
|
s15_insight). Vocabulary alignment only -- glossary edit in
|
|
§1 and docstring edits referring to rows as "episodes." No
|
|
schema delta.
|
|
|
|
**P6 -- First-class edges (Graphiti). DEFERRED.** See §12
|
|
OQ #18. Heavy add; conflicts with chain-id-as-string-template
|
|
in §8; needs its own scoping memo.
|
|
|
|
### 13.2 Temporality
|
|
|
|
**P3 -- Per-relation time-travel (Cozo bitemporal).** Today
|
|
trusty has system-time only (chain_timestamp); supersession is
|
|
a payload-level concept (`supersedes: [prior_row_hash]` per
|
|
s15_insight). Add a companion table:
|
|
|
|
```sql
|
|
CREATE TABLE bitemporal_index (
|
|
chain_id TEXT NOT NULL,
|
|
row_hash TEXT NOT NULL,
|
|
valid_range tstzrange NOT NULL,
|
|
PRIMARY KEY (chain_id, row_hash),
|
|
FOREIGN KEY (chain_id, row_hash)
|
|
REFERENCES provenance_envelope (chain_id, row_hash)
|
|
);
|
|
CREATE INDEX bitemporal_index_range
|
|
ON bitemporal_index USING GIST (chain_id, valid_range);
|
|
```
|
|
|
|
**Critical invariant: `provenance_envelope` columns and the
|
|
`build_payload` byte-shape are NEVER modified by P3.** The
|
|
bitemporal index is a sidecar projection keyed by
|
|
(chain_id, row_hash). Conformance suite vs. trusty-sqlite stays
|
|
green.
|
|
|
|
**P8 -- Bi-temporal supersession projection (Graphiti).** Ships
|
|
with P3. A Postgres view `current_facts` materializes
|
|
"facts whose `valid_range` contains `now()`," excluding
|
|
superseded rows. The supersession event itself remains a
|
|
chained envelope per s15_insight (never mutate prior rows);
|
|
P8 just makes the projection queryable.
|
|
|
|
### 13.3 Retrieval (unblocks the trusty-monitor-daemon pin)
|
|
|
|
**P4 -- HNSW vector indices on chained relations (Cozo).**
|
|
Adopt pgvector. Per chained relation that opts in (via the
|
|
registry from P1), an `embedding vector(N)` column on a
|
|
sidecar table keyed by `(chain_id, row_hash)` plus an HNSW
|
|
index. Existing anchor: pin
|
|
`../.kb-mesh/pins/trusty/trusty-monitor-daemon-chat-capture.md`
|
|
asks for "captures chat as unstructured data, auto-tags" --
|
|
auto-tag-over-chain is exactly pgvector + HNSW. Currently no
|
|
path; P4 is the path.
|
|
|
|
**P7 -- Hybrid retrieval contract (Graphiti).** One query API
|
|
combining vector similarity + tsvector keyword + recursive
|
|
graph traversal, with chain-verify result as a returned column
|
|
on every row. Existing anchor: `s15_insight` specifies
|
|
pre-write federation search as `kb_search(keywords) ->
|
|
Vec<RowRef>` across the user's schema + `received_bundles`.
|
|
P7 extends the signature to
|
|
`kb_search(keywords?, embedding?, graph_filter?) ->
|
|
Vec<VerifiedRowRef>`. Single API surface for both the kb-mesh
|
|
adapter (s15_insight) and the trusty-monitor-daemon pin.
|
|
|
|
### 13.4 Query API
|
|
|
|
**P2 -- Datalog-shaped chain queries via recursive CTEs.**
|
|
Existing anchor:
|
|
`s14_insight_general_library_improvements_from_postgres_impl`
|
|
item (1) -- promote `history`, `breaks_since`,
|
|
`liveness_check`, `integrity` to `trusty-store` as a
|
|
`TrustyExt` trait. P2 reframes that refactor: the trusty-store
|
|
side ships a named-relation query library; each backend
|
|
provides the templates (recursive CTEs on Postgres,
|
|
WITH RECURSIVE on SQLite, Datalog rules on a future Cozo
|
|
adapter). Same surface, backend-specific implementation.
|
|
|
|
### 13.5 Adoption summary
|
|
|
|
| Pattern | KB anchor | Verdict |
|
|
|---------|-----------------------------------------------------------------|--------------------------------------|
|
|
| P1 | `s15_insight_kb_surface_taxonomy` | Adopt -- completes four-surface mandate |
|
|
| P2 | `s14_insight_general_library_improvements_from_postgres_impl` | Adopt -- reframes promote-to-store refactor |
|
|
| P3 | `s15_insight_kb_surface_taxonomy` (supersession), §11c | Adopt -- companion table only |
|
|
| P4 | pin `trusty-monitor-daemon-chat-capture`, §11c federation | Adopt -- direct unblocker |
|
|
| P5 | `provenance_envelope` already exists | Adopt -- vocabulary only |
|
|
| P6 | §8 chain-id-as-template, §11c federation | Defer -- §12 OQ #18 |
|
|
| P7 | `s15_insight_kb_surface_taxonomy` (kb_search spec), pin | Adopt -- direct extension |
|
|
| P8 | `s15_insight_kb_surface_taxonomy` (supersession decision) | Adopt -- bundles with P3 |
|
|
|
|
### 13.6 Upstream blockers and locked-rule statement
|
|
|
|
- **v3 signed-path gap (§12 OQ #17, escalated to HARD BLOCKER
|
|
in `s15_decision_multitenant_shared_kb_shape`)** is upstream
|
|
of P1 / P4 / P7. Federated hybrid retrieval over
|
|
`received_bundles` is meaningless without verifiable v3
|
|
signed rows. Resolve OQ #17 before implementing §13.
|
|
- **Locked-rule check.** P1 adds a meta table. P2 ships pure
|
|
query library code. P3 adds a sidecar table keyed by
|
|
(chain_id, row_hash); never modifies `provenance_envelope`.
|
|
P4 adds a sidecar table per opted-in relation; never modifies
|
|
`provenance_envelope`. P5 is documentation. P7 is API
|
|
composition over P1 / P3 / P4. P8 is a view. **None touches
|
|
`compute_record`, `build_payload`, or the three core locked
|
|
columns.** Conformance suite vs. trusty-sqlite remains the
|
|
gate.
|
|
|
|
---
|
|
|
|
## Appendix A. Lessons applied -- index
|
|
|
|
| Lesson | Section | Where in design |
|
|
|---------------------------------|--------------------|-------------------------------------------------------------------------------|
|
|
| `insight-LCG-ARCH-01` meta | every section | "build it in v1 because retrofit is 5-10x more expensive" |
|
|
| `learn-LCG-ARCH-02` compaction | 5 | snapshot tables in v1; SNAPSHOT envelope; chain_compaction_state |
|
|
| `learn-LCG-ARCH-03` tri-state | 4 | `tip_state` column; `TipState` enum on the trait; cold-start representation |
|
|
| `learn-LCG-ARCH-06` revocation | 6 | `producer_revocation` table; REVOCATION envelope; runtime invariant test |
|
|
| `learn-LCG-ARCH-13` TCB | 1, 12 | TCB statement in README page one; postgres + libpq + driver named explicitly |
|
|
|
|
| Trusty insight | Section | Where |
|
|
|--------------------------------------------------------|---------|------------------------------------------------------------------|
|
|
| `s14_insight_oid_anchored_chain_identity` | 8 | `producer_identity` table; OID-as-entity_id pattern |
|
|
| `s14_insight_store_trait_multi_backend_landscape` | 2 | scope of higher-level Trusty methods to re-implement |
|
|
| `s14_learn_with_txn_async_friction` | 7 | Option (a) sync `postgres` crate; trait unchanged |
|
|
| `s5_invariant_v1_byte_shape_preserved` | 3, 11 | TEXT not JSONB for raw_payload; conformance test vs sqlite |
|
|
| `project_trusty_v2_signing_design` | 6, 4 | additive-only precedent for trait additions |
|
|
|
|
---
|
|
|
|
## Appendix B. Files referenced
|
|
|
|
- `crates/core/trusty-store/src/lib.rs:22-48` -- Store trait definition.
|
|
- `crates/core/trusty-store/src/lib.rs:54-115` -- LastRow, TipRow, StoredRow, RowInsert, TipUpsert, KvCrossCheckReport.
|
|
- `crates/adapters/trusty-sqlite/src/store.rs:1-955` -- reference adapter implementation including all higher-level Trusty methods.
|
|
- `crates/adapters/trusty-sqlite/src/schema.rs:1-49` -- reference schema (three tables, locked).
|
|
- `APERC/tags/s14_insight_oid_anchored_chain_identity.toml` -- OID-anchored model.
|
|
- `APERC/tags/s14_insight_store_trait_multi_backend_landscape.toml` -- four-backend landscape; method scope.
|
|
- `APERC/tags/s14_learn_with_txn_async_friction.toml` -- with_txn options.
|
|
- `../legal-chaingraph/APERC/KB/architecture_antipatterns.md` -- the 18-lesson source.
|
|
- `../legal-chaingraph/APERC/tags/learn-LCG-ARCH-02.toml` -- compaction.
|
|
- `../legal-chaingraph/APERC/tags/learn-LCG-ARCH-03.toml` -- tri-state null.
|
|
- `../legal-chaingraph/APERC/tags/learn-LCG-ARCH-06.toml` -- revocation.
|
|
- `../legal-chaingraph/APERC/tags/insight-LCG-ARCH-01.toml` -- meta-principle.
|
|
- `CLAUDE.md` -- project rules; locked surfaces; library-not-platform discipline.
|
|
- `docs/payload-v2.md` -- v2 payload signing design.
|
|
- `docs/threat-model.md` -- existing trusty threat model (cross-reference for TCB).
|
|
|
|
End of memo.
|