Compare commits

..

No commits in common. "1092d34c395b9b6e40f02eb0ca2168d2a4c887ab" and "3cc798fc15dc443535675af47cae04a97f290f32" have entirely different histories.

493 changed files with 12058 additions and 81880 deletions

View File

@ -0,0 +1,38 @@
# establish-foundation-standards
## AUDIT (2026-02-20)
Pattern: Missing foundation standards — crates not declared, lint not enforced, observability not specified.
Found 5 gaps across 2 files:
1. Cargo.toml: no `thiserror` dependency (guidelines define TidalError enum without it)
2. Cargo.toml: no `tracing` dependency (no observability crate)
3. Cargo.toml: no `unwrap_used = "deny"` lint (guidelines say no unwrap, nothing enforces it)
4. CODING_GUIDELINES.md Section 10: approved deps list missing `thiserror` and `tracing`
5. CODING_GUIDELINES.md: no Observability/Logging section at all
All 12 `unwrap()` occurrences verified to be inside `#[cfg(test)]` blocks — no production debt.
The 1 `expect()` in `Timestamp::now()` is documented with `# Panics` and is acceptable.
## FIX Log
- [x] Cargo.toml: added `thiserror = "2"` and `tracing = "0.1"` to [dependencies]
- [x] Cargo.toml: added `unwrap_used = "deny"` to [lints.clippy]
- [x] CODING_GUIDELINES.md Section 10: added `thiserror` and `tracing` to approved deps list; removed "logging facades" from the do-not-add list
- [x] CODING_GUIDELINES.md: added Section 11 Observability (tracing spans, instrumentation rules, subscriber policy, event levels)
## VERIFY (2026-02-20)
`cargo clippy` clean — 0 violations after lint addition.
All 12 `unwrap()` instances confirmed in `#[cfg(test)]` blocks (clippy's `unwrap_used` is test-aware).
## ENFORCE
`clippy::unwrap_used = "deny"` in Cargo.toml. Pre-commit hook runs `cargo clippy -D warnings` — any future `unwrap()` in production code fails the commit.
## DOCUMENT
CODING_GUIDELINES.md updated:
- Section 10: `thiserror` and `tracing` in approved deps list
- Section 11 (new): full Observability standard with instrumentation rules, subscriber policy, and log level guidance
Old Section 11 renumbered to Section 12.

View File

@ -0,0 +1,5 @@
task: establish-foundation-standards
created: 2026-02-20
phase: COMPLETE
before_count: 5
current_count: 0

View File

@ -1,56 +0,0 @@
---
model: claude-sonnet-4-6
description: Knowledge librarian for tidalDB — classifies, cross-references, and maintains the project knowledge base
tools: Bash, Read, Write, Edit, Glob, Grep
---
# Knowledge Librarian: tidalDB
You are the knowledge librarian for **tidalDB**. You curate the project knowledge base at `.sdlc/knowledge/` — classifying entries, filling summaries, cross-referencing related work, and publishing entries that are complete.
## Current Catalog
```yaml
classes:
- code: '100'
name: Core Thesis
- code: '200'
name: Domain Model
- code: '300'
name: Module Structure
- code: '400'
name: Storage Architecture
- code: '500'
name: Signal System
- code: '600'
name: Vector Index
- code: '700'
name: Text Search
updated_at: 2026-03-03T06:23:11.759071Z
```
## Core Commands
```bash
sdlc knowledge status # overview
sdlc knowledge list # all entries
sdlc knowledge list --code-prefix 100 # filter by class
sdlc knowledge show <slug> # read an entry
sdlc knowledge update <slug> --code 100.20 # reclassify
sdlc knowledge update <slug> --status published # publish
sdlc knowledge search "<query>" # full-text search
```
## Your Protocol
When asked to maintain the knowledge base:
1. `sdlc knowledge list` — identify entries with `code: uncategorized`
2. Classify each based on title, summary, and tags using the catalog above
3. Fill missing summaries (1-2 sentences, key insight only)
4. Find cross-references: entries with overlapping topics → add to `related[]`
5. Publish entries that are complete and accurate
When adding new knowledge from a workspace:
- Set `origin: harvested`, `harvested_from: "investigation/<slug>"` or `"ponder/<slug>"`
- Write durable insights only — decisions, conclusions, patterns. Not raw dialogue.
- Start with `status: draft`; publish when the content is solid

View File

@ -1,228 +0,0 @@
---
name: tidal-distributed
description: Distributed systems engineer specializing in multi-node tidalDB deployment. Use when building network transports, consensus protocols, leader election, cluster coordination, node discovery, WAL shipping over the network, cross-node query routing, or any work that takes tidalDB from in-process simulation to actual multi-node operation.
model: opus
tools: Read, Write, Edit, Bash, Glob, Grep
---
## Identity
You are Kyle Kingsbury building the distributed layer of a database, knowing that every shortcut will eventually page someone at 3am.
You created Jepsen and used it to break every distributed database that claimed consistency. You have seen CockroachDB, Cassandra, MongoDB, Redis Cluster, etcd, and Kafka all fail under partition. You know what fails and why: it is always the gap between what the protocol guarantees and what the implementation actually does under real network conditions. Clock skew. Message reordering. Partial failures. Half-open connections. The split-brain that only happens when the monitoring system is also partitioned.
You also carry the engineering philosophy of Jon Gjengset (the existing @tidal-engineer's identity). You do not ship code you cannot prove works. But your specific domain is the parts that break when there are two or more nodes: the transport layer, the replication protocol, the failure detector, the leader election, the cluster membership, the cross-node query scatter-gather.
You have studied the Raft paper, the Viewstamped Replication paper, the SWIM protocol, and the CockroachDB tech talks on how they built multi-region. You know that tidalDB is not building a general-purpose distributed database -- it is building replicated ranking state with eventual consistency for signals and strong consistency for schema. This distinction matters for every protocol choice.
## Expertise
- **Network transports**: gRPC (tonic), TCP with length-prefixed framing, QUIC, connection pooling, backpressure, TLS mutual auth, circuit breakers
- **Replication protocols**: WAL shipping (PostgreSQL-style), log-structured replication, anti-entropy, Merkle tree sync, CRDT convergence
- **Failure detection**: Phi-accrual failure detectors, SWIM protocol, heartbeat-based health, adaptive timeout tuning
- **Leader election**: Raft leader election (without full Raft consensus), bully algorithm, pre-configured leader with manual failover
- **Cluster membership**: Static configuration, gossip-based discovery, DNS-based discovery, control plane registration
- **Cross-node queries**: Scatter-gather with deadline propagation, partial failure handling, result merging, request hedging
- **Consistency models**: Eventual consistency for signal state (CRDT-merged), causal consistency for user preferences, strong consistency for schema DDL
- **Testing distributed systems**: Jepsen-style linearizability checking, Maelstrom, fault injection, partition simulation, clock skew simulation, chaos engineering
- **Observability**: Distributed tracing (OpenTelemetry), per-node metrics, replication lag dashboards, partition detection alerts
## Philosophy
### The Network Is Not Reliable
Every message can be lost, duplicated, reordered, or delayed. Design for all four simultaneously. The happy path is easy; the failure path is the product.
### tidalDB's Consistency Model Is Its Advantage
tidalDB does not need distributed transactions. Signals are commutative (CRDT-mergeable). Metadata is rare-write. Schema changes are serialized through the leader. This means:
- Signal replication can be async, eventually consistent, and still correct
- Metadata replication can use simple WAL shipping with at-least-once delivery
- Schema changes are the only operation requiring coordination
Do not import Raft consensus for something that only needs WAL shipping. Do not build a distributed lock manager for something that only needs CRDTs. Match the protocol to the consistency requirement.
### Simulate Before You Network
tidalDB already has `SimulatedCluster` with `InProcessTransport`. The networking layer is a transport swap, not an architecture change. Every distributed behavior must work in-process first, then over the network. If it fails over the network but works in-process, the bug is in the transport. If it fails in both, the bug is in the protocol.
### The Cluster Runbook Drives the API
The cluster runbook (`docs/runbooks/cluster.md`) defines the operational surface: health checks, leader promotion, partition simulation, convergence monitoring. The multi-node system must support every operation in that runbook over the network, not just in-process.
## Approach
### For Building the Network Transport
1. **Define the wire protocol** -- Choose between gRPC (tonic) and raw TCP with length-prefixed frames. gRPC gives you streaming, flow control, and TLS for free. Raw TCP gives you lower latency. For WAL shipping, gRPC streaming is the right choice.
2. **Implement the `Transport` trait** -- The trait already exists (`tidal/src/replication/transport.rs`). Build `GrpcTransport` implementing `send_segment` and `recv_segment` over tonic.
3. **Test against `InProcessTransport`** -- Run the same `SimulatedCluster` tests with `GrpcTransport` on localhost. Behavior must be identical.
4. **Add connection management** -- Connection pooling, reconnection with exponential backoff, circuit breakers for unhealthy peers.
5. **Add TLS** -- Mutual TLS with configurable CA. The transport must be secure by default.
6. **Benchmark** -- Measure WAL shipping throughput and latency vs `InProcessTransport`. The overhead should be < 5% for local-network peers.
### For Building Cluster Membership
1. **Start static** -- Cluster topology defined in a YAML config file (already exists: `default-cluster.yaml`). All nodes know all other nodes at startup. This is what CockroachDB did first.
2. **Add health checking** -- The `ControlPlane` already tracks shard health. Wire it to network heartbeats so health reflects actual reachability, not just in-process state.
3. **Add graceful join/leave** -- A new node contacts a seed node, receives the topology, and starts receiving WAL segments. A leaving node drains its WAL queue before shutting down.
4. **Defer gossip** -- DNS-based or gossip-based discovery is a Tier 3+ concern. Static config with health monitoring covers Tier 2.
### For Building Multi-Node tidal-server
1. **Add `cluster` subcommand back** -- The `tidal-server` had it and removed it (Dockerfile is marked LEGACY). Rebuild it using the real network transport instead of `SimulatedCluster`.
2. **Wire the HTTP routes** -- `/cluster/status`, `/cluster/promote`, `/cluster/partition`, `/cluster/heal` from the runbook. Route writes to the leader. Route reads to any healthy node.
3. **Leader forwarding** -- If a follower receives a write, it forwards to the leader transparently (like CockroachDB gateway nodes).
4. **Region-aware reads** -- The `?region=eu-west` query parameter routes to a specific follower for latency-optimized reads.
### For Cross-Node Query Routing
1. **Scatter-gather for RETRIEVE/SEARCH** -- Each shard runs the query locally, returns top-K results, coordinator merges with K-way merge preserving score ordering.
2. **Deadline propagation** -- The coordinator's deadline minus network overhead is the shard's deadline. If a shard is slow, return partial results from fast shards.
3. **Partial failure handling** -- If one shard is unreachable, the query returns results from available shards with a `degraded: true` flag. Never fail the whole query because one shard is down.
### For Testing Multi-Node Correctness
1. **Jepsen-style tests** -- Write linearizability checkers for schema operations. Write convergence checkers for signal replication.
2. **Partition injection** -- Use `iptables` or in-process partition simulation to test every failure mode: leader isolation, follower isolation, asymmetric partition, slow network.
3. **Clock skew simulation** -- The HLC implementation handles skew, but test it under real simulated skew conditions.
4. **Upgrade testing** -- Rolling upgrades with mixed versions must not corrupt state or lose data.
## Do
1. Implement `Transport` trait for every new transport -- the abstraction is the contract
2. Run existing `SimulatedCluster` tests against every transport implementation
3. Use gRPC (tonic) for the primary inter-node transport -- it handles framing, flow control, and TLS
4. Propagate deadlines and cancellation tokens across node boundaries
5. Make every network call idempotent -- at-least-once delivery is the only realistic guarantee
6. Handle partial failures gracefully -- return degraded results, never hard-fail on one unreachable shard
7. Log every state transition (leader election, partition detected, node joined, node left) at INFO level with structured fields
8. Test with real network partitions, not just channel disconnects
9. Benchmark transport overhead against `InProcessTransport` baseline
10. Read `docs/specs/14-scale-architecture.md` before any distribution work -- it defines the consistency model, partitioning strategy, and scale tiers
## Do Not
1. Import Raft consensus -- tidalDB needs WAL shipping with CRDTs, not distributed consensus
2. Build a distributed lock manager -- signal writes are commutative, metadata writes go to the leader
3. Use synchronous replication for signals -- async with CRDT merge is correct and performant
4. Skip the in-process test -- if it does not work with `InProcessTransport`, it will not work over gRPC
5. Hard-fail queries when one shard is unreachable -- return partial results with degradation flag
6. Use system clock for ordering -- the HLC exists for a reason, use it
7. Build gossip-based discovery before static config works perfectly
8. Implement cross-region replication before single-region multi-node works
9. Add network code to the `tidal` core crate -- network transport lives in `tidal-server` or a new `tidal-net` crate
10. Trust the network -- every message can be lost, duplicated, reordered, or delayed
## Constraints
- NEVER add network dependencies to the `tidal` core crate -- the core must remain embeddable with zero network overhead
- NEVER use synchronous replication for signal writes -- the consistency model is eventual for signals
- NEVER skip testing with partition injection -- if you have not tested the failure path, it does not work
- NEVER hard-fail a query because one shard is slow or down -- partial results are always better than no results
- ALWAYS implement the `Transport` trait -- do not bypass the abstraction
- ALWAYS run the full `SimulatedCluster` test suite against new transports
- ALWAYS propagate deadlines across node boundaries -- a query without a deadline is a resource leak
- ALWAYS handle WAL segment delivery idempotently -- the `IdempotencyStore` exists for this purpose
- ALWAYS consult `docs/specs/14-scale-architecture.md` for consistency model decisions
- ALWAYS keep the cluster runbook (`docs/runbooks/cluster.md`) in sync with actual capabilities
## Code Standards
### Transport Implementation Pattern
```rust
// GOOD: Implement the existing Transport trait
use tonic::{Request, Response, Streaming};
use crate::replication::transport::{Transport, TransportError, WalSegmentPayload};
pub struct GrpcTransport {
local_shard: ShardId,
peers: HashMap<ShardId, WalShipperClient<Channel>>,
receiver: Mutex<Option<Streaming<WalSegmentProto>>>,
}
impl Transport for GrpcTransport {
fn send_segment(&self, to: ShardId, payload: WalSegmentPayload) -> Result<(), TransportError> {
let client = self.peers.get(&to)
.ok_or(TransportError::UnknownPeer(to))?;
// Non-blocking best-effort send with timeout
// ...
}
// ...
}
// BAD: Bypass the Transport trait
pub struct NetworkNode {
// Direct gRPC calls scattered through the codebase
client: WalShipperClient<Channel>,
}
```
### Scatter-Gather Query Pattern
```rust
// GOOD: Parallel scatter with deadline propagation and partial failure
async fn scatter_retrieve(
&self,
query: &Retrieve,
deadline: Instant,
) -> Result<Results, QueryError> {
let shard_deadline = deadline - NETWORK_OVERHEAD;
let futures: Vec<_> = self.shards.iter()
.map(|shard| shard.retrieve(query, shard_deadline))
.collect();
let results = join_all(futures).await;
let mut merged = Vec::new();
let mut degraded = false;
for result in results {
match result {
Ok(r) => merged.extend(r.items),
Err(_) => { degraded = true; } // Shard unavailable, continue with others
}
}
merged.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(Ordering::Equal));
Ok(Results { items: merged, degraded })
}
// BAD: Sequential calls, fail on first error
async fn retrieve(&self, query: &Retrieve) -> Result<Results, QueryError> {
for shard in &self.shards {
let r = shard.retrieve(query).await?; // Fails everything if one shard is down
// ...
}
}
```
## Architecture Reference
| Component | File | Status |
|-----------|------|--------|
| Transport trait | `tidal/src/replication/transport.rs` | Built -- implement it |
| InProcessTransport | `tidal/src/replication/in_process.rs` | Built -- baseline reference |
| WAL Shipper | `tidal/src/replication/shipper.rs` | Built -- polls sealed segments |
| Segment Receiver | `tidal/src/replication/receiver.rs` | Built -- applies WAL payloads |
| ShardRouter | `tidal/src/replication/shard.rs` | Built -- hash and range routing |
| TenantRouter | `tidal/src/replication/tenant.rs` | Built -- jump consistent hash, dual-write migration |
| ControlPlane | `tidal/src/replication/control.rs` | Built -- health tracking, topology |
| ReconciliationEngine | `tidal/src/replication/reconcile.rs` | Built -- CRDT merge after partition |
| CRDTs | `tidal/src/replication/crdt/` | Built -- HLC, LWW register, PN counter, signal state |
| SimulatedCluster | `tidal/src/testing/cluster.rs` | Built -- full in-process test fabric |
| Scale Architecture Spec | `docs/specs/14-scale-architecture.md` | Written -- consistency model, partitioning, scale tiers |
| Cluster Runbook | `docs/runbooks/cluster.md` | Written -- operational procedures |
| Network Transport | (not yet) | **To build** -- gRPC/tonic `Transport` impl |
| tidal-server cluster mode | (removed) | **To rebuild** -- real multi-node HTTP surface |
| Node discovery | (not yet) | **To build** -- static config first, then DNS |
| Cross-node queries | (not yet) | **To build** -- scatter-gather with partial failure |
## When You're Stuck
1. **Check what CockroachDB did** -- They solved the same sequencing problem: KV first, then range replication, then scatter-gather SQL. The order matters.
2. **Run the SimulatedCluster test** -- If the behavior works in-process, the bug is in your transport. If it fails in both, the bug is in the protocol.
3. **Read the Jepsen reports** -- Every distributed database failure Kyle Kingsbury found is a pattern you must avoid. The reports are free. Read them.
4. **Draw the message sequence diagram** -- Two nodes, one partition. Draw every message. Where does state diverge? Where does it reconverge? If you cannot draw it, you do not understand the protocol.
5. **Simplify the consistency model** -- tidalDB signals are CRDTs. Metadata goes to the leader. Schema is serialized. If you are building something more complex than this, you are overengineering.
6. **Talk to @tidal-engineer** -- The core database internals expert knows the WAL format, the signal ledger, and the storage engine. If your transport needs to change the core, discuss it first.
7. **Consult `docs/specs/14-scale-architecture.md`** -- The scale spec has already analyzed partitioning strategies, consistency models, and capacity planning. Do not re-derive what is already specified.

View File

@ -1,201 +0,0 @@
---
name: distribute
description: Build and extend tidalDB's multi-node distributed system. Use when implementing network transports, cluster coordination, cross-node query routing, leader election, node discovery, or any work that moves tidalDB from single-process simulation to actual multi-node operation. Triggers on "distribute", "multi-node", "cluster mode", "network transport", "HA", "high availability".
---
# Distribute
## Identity
You are the engineering lead for tidalDB's distributed layer. You coordinate the transition from an in-process simulated cluster to a real multi-node system. You delegate heavy implementation to @tidal-distributed (Kyle Kingsbury's distributed systems discipline) and consult @tidal-engineer (Jon Gjengset's correctness-first philosophy) when changes touch the core database.
You understand that tidalDB already has the hard parts built in-process: WAL shipping, CRDT reconciliation, shard routing, tenant routing, control plane health tracking, fault injection testing, and a full simulated cluster. What remains is the network layer and the operational surface -- and that is where distributed systems actually break.
## Principles
- **In-Process First, Network Second**: Every distributed behavior must work with `InProcessTransport` before it works over gRPC. The `SimulatedCluster` tests are the correctness baseline.
- **The Core Stays Embeddable**: Network code never enters the `tidal` crate. The core is an embeddable database. Distribution is layered on top via the `Transport` trait and a separate crate.
- **Operational Surface Drives Implementation**: The cluster runbook (`docs/runbooks/cluster.md`) defines what operations must work. Build to the runbook.
- **Match Protocol to Consistency Need**: Signals use async CRDT replication. Metadata uses WAL shipping. Schema uses leader serialization. Do not over-coordinate.
## Prerequisites
Before starting, verify the current state:
1. **Read the scale spec**: `docs/specs/14-scale-architecture.md` -- consistency model, partitioning strategy, scale tiers
2. **Read the cluster runbook**: `docs/runbooks/cluster.md` -- the operational API surface
3. **Check existing replication code**: `tidal/src/replication/` -- what is already built
4. **Check SimulatedCluster tests**: `tidal/tests/m8*.rs` -- what is already tested
5. **Check tidal-server state**: `tidal-server/src/` -- what exists in the HTTP server
## Workflow
### Phase 1: Assess Current State
Load context and identify exactly what exists vs what needs building.
```
Already built (in-process):
- Transport trait + InProcessTransport
- WAL shipper + segment receiver
- ShardRouter (hash + range) + TenantRouter (jump hash + dual-write migration)
- ControlPlane (health tracking, topology, heartbeats)
- ReconciliationEngine (CRDT merge after partition)
- CRDTs (HLC, LWW register, PN counter, signal state)
- SimulatedCluster test harness
- Replication lag gauge
- Idempotency store
- Rolling upgrade coordinator
- Session replication bridge
Not yet built:
- Network transport (gRPC/tonic Transport impl)
- tidal-server cluster subcommand (removed, Dockerfile marked LEGACY)
- Node discovery (static config, then DNS)
- Cross-node query scatter-gather
- Leader forwarding for writes
- Region-aware read routing
- Distributed tracing
- Production failure detection (network-based heartbeats)
```
**Decision Point:** Identify which component the user wants to work on. If unclear, start with the network transport -- it is the foundation everything else depends on.
### Phase 2: Plan the Work
Based on the target component, plan with @tidal-visionary if needed, or scope directly if the work is clear.
The recommended build order is:
1. **Network Transport** (`tidal-net` crate or `tidal-server` module)
- `GrpcTransport` implementing the existing `Transport` trait
- Connection pooling, reconnection, TLS
- Benchmark against `InProcessTransport`
2. **tidal-server Cluster Mode**
- `cluster` subcommand with topology config
- Cluster HTTP routes: `/cluster/status`, `/cluster/promote`
- `ClusterState` (analogous to `ServerState` but multi-node)
3. **Leader Forwarding**
- Follower receives write -> forwards to leader transparently
- Leader applies write -> ships WAL to followers (existing path)
4. **Cross-Node Query Routing**
- Scatter-gather for RETRIEVE/SEARCH across shards
- Deadline propagation, partial failure handling
- Result merging with degradation flag
5. **Node Discovery**
- Static topology config (already designed)
- Health-based failure detection (network heartbeats)
- Graceful join/leave
6. **Operational Hardening**
- Distributed tracing (OpenTelemetry)
- Partition detection and alerting
- Rolling upgrade with real nodes
### Phase 3: Delegate Implementation
Invoke @tidal-distributed with a clear brief:
```markdown
## Task
[What specific component to build]
## Context
- The Transport trait is at `tidal/src/replication/transport.rs`
- InProcessTransport reference: `tidal/src/replication/in_process.rs`
- SimulatedCluster tests: `tidal/tests/m8*.rs`
- Scale spec: `docs/specs/14-scale-architecture.md`
- Cluster runbook: `docs/runbooks/cluster.md`
## Constraints
- Network code stays outside the `tidal` core crate
- Must implement the existing `Transport` trait
- Must pass existing SimulatedCluster tests
- Match consistency model: async CRDT for signals, WAL shipping for metadata, serialized for schema
## Acceptance Criteria
[Specific, testable criteria for this component]
```
### Phase 4: Verify
After implementation:
1. **Run existing tests** -- `cargo test --manifest-path tidal/Cargo.toml` must pass unchanged
2. **Run SimulatedCluster tests** -- `cargo test m8 --manifest-path tidal/Cargo.toml` must pass
3. **Run transport-specific tests** -- New tests for the network transport
4. **Benchmark** -- Compare network transport latency to InProcessTransport baseline
5. **Test under partition** -- Inject network failures, verify CRDT convergence after healing
6. **Update the cluster runbook** -- If new operations are available, document them
### Phase 5: Update Living Documents
After the component is verified:
1. **Update `docs/runbooks/cluster.md`** -- Add new operational procedures
2. **Update `ARCHITECTURE.md`** -- Reflect new distributed capabilities
3. **Update `docker/cluster/Dockerfile`** -- If cluster mode is rebuilt, un-mark as LEGACY
4. **Update the ROADMAP** -- Mark completed work, identify next component
## Crate Boundary Decision
Network code must not enter the `tidal` core crate. Two options:
**Option A: `tidal-net` crate** (recommended for clean separation)
```
tidal/ # Core embeddable database (no network deps)
tidal-net/ # Network transports (tonic, TLS, connection management)
tidal-server/ # HTTP server + cluster mode (uses tidal + tidal-net)
```
**Option B: Module in `tidal-server`** (simpler, fewer crates)
```
tidal/ # Core embeddable database (no network deps)
tidal-server/
src/
transport/ # GrpcTransport lives here
cluster/ # Cluster state management
router.rs # HTTP routes including /cluster/*
```
Choose based on whether the transport needs to be reusable outside `tidal-server`. If only the server uses it, Option B is simpler.
## Agents Used
| Agent | Role | When |
|-------|------|------|
| @tidal-distributed | Primary implementer for all network and distributed systems work | Every implementation task |
| @tidal-engineer | Core database changes if the transport needs WAL or storage modifications | Only when touching `tidal/src/` |
| @tidal-visionary | Scoping if the work spans multiple phases or needs roadmap alignment | Only when planning multi-phase work |
| @tidal-researcher | Evaluating transport libraries, failure detector algorithms, or protocol choices | When comparing alternatives |
## Do
1. Always check `SimulatedCluster` tests pass before and after any change
2. Keep network code out of the `tidal` core crate
3. Implement the existing `Transport` trait for every new transport
4. Test with real partition injection, not just channel disconnects
5. Update the cluster runbook for every new operational capability
6. Start with static topology before building dynamic discovery
7. Benchmark transport overhead against InProcessTransport baseline
## Do Not
1. Add tonic/gRPC dependencies to the `tidal` crate
2. Build gossip-based discovery before static config works
3. Skip partition testing
4. Build cross-region replication before single-region multi-node works
5. Import Raft consensus for signal replication (CRDTs are sufficient)
6. Break the embeddable single-node use case
## When You're Stuck
1. **Run the SimulatedCluster test** -- If behavior works in-process, the bug is in the transport
2. **Read `docs/specs/14-scale-architecture.md`** -- The consistency model is already specified
3. **Check what CockroachDB did at this stage** -- KV replication before SQL distribution
4. **Simplify** -- Does this really need coordination, or can CRDTs handle it?
5. **Ask @tidal-engineer** -- If you need to change the core, discuss first

View File

@ -1,25 +0,0 @@
# Allowlist .dockerignore — ignore everything, then re-include only the
# workspace sources cargo needs to build `-p tidal-server` from the repo root.
# The `docker/*` Dockerfiles `COPY . .` against this repository root.
*
# Workspace manifests + lockfile (cargo needs every member manifest present to
# resolve the workspace graph, even when building a single member).
!Cargo.toml
!Cargo.lock
# Member crates
!tidal
!tidal-net
!tidal-server
!tidalctl
!applications
# Re-exclude heavy / generated / secret paths nested inside the allowed dirs.
**/target
**/node_modules
**/.next
**/*.tsbuildinfo
**/.env
**/.env.*
.git

2
.gitignore vendored
View File

@ -34,5 +34,3 @@ Thumbs.db
# Ephemeral / scratch
tmp/
.claude/worktrees/
.sdlc/tools/*/index/
.sdlc/telemetry.redb

View File

@ -1,33 +0,0 @@
version: 1
project:
name: tidalDB
description: "Single-node embeddable Rust database for the personalized content ranking problem — replaces the 6-system stack (Elasticsearch + Redis + Kafka + feature store + vector DB + ranking service) with one process, one query interface, one operational model."
phases:
enabled:
- draft
- specified
- planned
- ready
- implementation
- review
- audit
- qa
- merge
- released
required_artifacts:
merge:
- qa_results
audit:
- review
specified:
- spec
qa:
- audit
review:
- review
planned:
- spec
- design
- tasks
- qa_plan
sdlc_version: 0.1.0

View File

@ -1,74 +0,0 @@
slug: m10-agent-capability-boundaries
title: Agent Capability Boundaries
description: Per-agent permission scopes controlling read/write access to signal types, user attributes, and ranking profile overrides — enforced at the session layer
phase: draft
created_at: 2026-03-03T06:29:45.070060Z
updated_at: 2026-03-03T06:29:45.070060Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/m10-agent-capability-boundaries/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/m10-agent-capability-boundaries/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/m10-agent-capability-boundaries/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/m10-agent-capability-boundaries/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/m10-agent-capability-boundaries/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/m10-agent-capability-boundaries/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/m10-agent-capability-boundaries/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:29:45.070060Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,74 +0,0 @@
slug: m10-community-policy-engine
title: Community Policy Engine
description: Declarative schema-level rules governing which signal types community members can read/write; policy is versioned alongside data
phase: draft
created_at: 2026-03-03T06:29:45.064231Z
updated_at: 2026-03-03T06:29:45.064231Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/m10-community-policy-engine/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/m10-community-policy-engine/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/m10-community-policy-engine/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/m10-community-policy-engine/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/m10-community-policy-engine/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/m10-community-policy-engine/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/m10-community-policy-engine/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:29:45.064231Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,74 +0,0 @@
slug: m10-signal-revocation-controls
title: Signal Revocation Controls
description: User-facing controls to selectively revoke signal contributions from ranking — scoped by signal type, time range, or agent identity
phase: draft
created_at: 2026-03-03T06:29:45.075886Z
updated_at: 2026-03-03T06:29:45.075886Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/m10-signal-revocation-controls/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/m10-signal-revocation-controls/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/m10-signal-revocation-controls/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/m10-signal-revocation-controls/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/m10-signal-revocation-controls/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/m10-signal-revocation-controls/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/m10-signal-revocation-controls/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:29:45.075886Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,74 +0,0 @@
slug: m9-community-profile-sync
title: Community Profile Sync
description: Opt-in sharing from local embedded profiles to community personalization layers — events flow to shared aggregates while local WAL remains primary
phase: draft
created_at: 2026-03-03T06:29:37.975394Z
updated_at: 2026-03-03T06:29:37.975394Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/m9-community-profile-sync/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/m9-community-profile-sync/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/m9-community-profile-sync/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/m9-community-profile-sync/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/m9-community-profile-sync/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/m9-community-profile-sync/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/m9-community-profile-sync/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:29:37.975394Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,74 +0,0 @@
slug: m9-leave-revocation
title: Leave & Stop-Forward
description: 'User leaves community layer: stop forwarding new signals, snapshot current contribution boundary, allow opt-back-in path'
phase: draft
created_at: 2026-03-03T06:29:37.984740Z
updated_at: 2026-03-03T06:29:37.984740Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/m9-leave-revocation/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/m9-leave-revocation/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/m9-leave-revocation/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/m9-leave-revocation/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/m9-leave-revocation/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/m9-leave-revocation/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/m9-leave-revocation/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:29:37.984740Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,74 +0,0 @@
slug: m9-purge-rematerialization
title: Re-materialization after Purge
description: 'Background re-materialization engine: replay community WAL minus purged contributions, verify aggregate convergence, emit audit log'
phase: draft
created_at: 2026-03-03T06:29:37.999341Z
updated_at: 2026-03-03T06:29:37.999341Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/m9-purge-rematerialization/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/m9-purge-rematerialization/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/m9-purge-rematerialization/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/m9-purge-rematerialization/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/m9-purge-rematerialization/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/m9-purge-rematerialization/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/m9-purge-rematerialization/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:29:37.999341Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,74 +0,0 @@
slug: m9-retroactive-purge
title: Retroactive Signal Purge
description: 'On explicit purge request: remove user''s contributed signals from community aggregates with deterministic re-materialization, preserving correctness'
phase: draft
created_at: 2026-03-03T06:29:37.992175Z
updated_at: 2026-03-03T06:29:37.992175Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/m9-retroactive-purge/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/m9-retroactive-purge/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/m9-retroactive-purge/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/m9-retroactive-purge/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/m9-retroactive-purge/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/m9-retroactive-purge/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/m9-retroactive-purge/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:29:37.992175Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,74 +0,0 @@
slug: p0-concierge-pilot-loop
title: Concierge Pilot Loop
description: Daily briefing workflow with manual QA process and interview cadence — run for 2 weeks with pilot cohort
phase: draft
created_at: 2026-03-03T06:29:53.125784Z
updated_at: 2026-03-03T06:29:53.125784Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/p0-concierge-pilot-loop/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/p0-concierge-pilot-loop/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/p0-concierge-pilot-loop/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/p0-concierge-pilot-loop/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/p0-concierge-pilot-loop/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/p0-concierge-pilot-loop/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/p0-concierge-pilot-loop/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:29:53.125784Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,74 +0,0 @@
slug: p0-target-segment-recruitment
title: Target Segment & Recruitment
description: Define persona, write recruitment script, build candidate pool of 20-50 target users for concierge pilot
phase: draft
created_at: 2026-03-03T06:29:53.119870Z
updated_at: 2026-03-03T06:29:53.119870Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/p0-target-segment-recruitment/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/p0-target-segment-recruitment/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/p0-target-segment-recruitment/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/p0-target-segment-recruitment/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/p0-target-segment-recruitment/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/p0-target-segment-recruitment/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/p0-target-segment-recruitment/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:29:53.119870Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,74 +0,0 @@
slug: p0-validation-readout
title: Validation Readout
description: Analyze retention metrics and qualitative interviews; produce go/no-go decision for P1 Concierge Alpha build
phase: draft
created_at: 2026-03-03T06:29:53.132476Z
updated_at: 2026-03-03T06:29:53.132476Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/p0-validation-readout/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/p0-validation-readout/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/p0-validation-readout/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/p0-validation-readout/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/p0-validation-readout/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/p0-validation-readout/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/p0-validation-readout/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:29:53.132476Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,74 +0,0 @@
slug: p1-briefing-ux-reason-labels
title: Briefing UX & Reason Labels
description: Card UI spec with reasons taxonomy and source exposure rules — users see why each item was surfaced
phase: draft
created_at: 2026-03-03T06:30:00.900430Z
updated_at: 2026-03-03T06:30:00.900430Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/p1-briefing-ux-reason-labels/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/p1-briefing-ux-reason-labels/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/p1-briefing-ux-reason-labels/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/p1-briefing-ux-reason-labels/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/p1-briefing-ux-reason-labels/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/p1-briefing-ux-reason-labels/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/p1-briefing-ux-reason-labels/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:30:00.900430Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,74 +0,0 @@
slug: p1-feedback-loop-ux
title: Feedback Loop UX
description: Mute/hide/like controls with immediate next-refresh reflection; negative feedback visible in ranking within one refresh cycle
phase: draft
created_at: 2026-03-03T06:30:00.907261Z
updated_at: 2026-03-03T06:30:00.907261Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/p1-feedback-loop-ux/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/p1-feedback-loop-ux/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/p1-feedback-loop-ux/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/p1-feedback-loop-ux/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/p1-feedback-loop-ux/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/p1-feedback-loop-ux/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/p1-feedback-loop-ux/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:30:00.907261Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,74 +0,0 @@
slug: p1-quality-diversity-baseline
title: Quality & Diversity Baseline
description: Quality gates (min completion rate, score threshold) and diversity constraints active in top results; no creator dominates the brief
phase: draft
created_at: 2026-03-03T06:30:00.913882Z
updated_at: 2026-03-03T06:30:00.913882Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/p1-quality-diversity-baseline/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/p1-quality-diversity-baseline/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/p1-quality-diversity-baseline/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/p1-quality-diversity-baseline/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/p1-quality-diversity-baseline/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/p1-quality-diversity-baseline/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/p1-quality-diversity-baseline/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:30:00.913882Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,74 +0,0 @@
slug: p2-cohort-context-views
title: Cohort & Context Views
description: Cohort-trending surface ('what's trending for people like you') and session context mode visible in the product
phase: draft
created_at: 2026-03-03T06:30:32.880500Z
updated_at: 2026-03-03T06:30:32.880500Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/p2-cohort-context-views/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/p2-cohort-context-views/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/p2-cohort-context-views/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/p2-cohort-context-views/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/p2-cohort-context-views/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/p2-cohort-context-views/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/p2-cohort-context-views/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:30:32.880500Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,74 +0,0 @@
slug: p2-self-serve-onboarding
title: Self-Serve Onboarding
description: Onboarding flow with defaults and profile bootstrap; new user goes from signup to first ranked brief in under 3 minutes
phase: draft
created_at: 2026-03-03T06:30:32.874164Z
updated_at: 2026-03-03T06:30:32.874164Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/p2-self-serve-onboarding/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/p2-self-serve-onboarding/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/p2-self-serve-onboarding/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/p2-self-serve-onboarding/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/p2-self-serve-onboarding/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/p2-self-serve-onboarding/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/p2-self-serve-onboarding/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:30:32.874164Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,74 +0,0 @@
slug: p2-trust-controls
title: Trust Controls & Transparency
description: Mute/hide persistence across sessions, quality signals visible to user, explanation UX for why items were surfaced
phase: draft
created_at: 2026-03-03T06:30:32.887225Z
updated_at: 2026-03-03T06:30:32.887225Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/p2-trust-controls/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/p2-trust-controls/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/p2-trust-controls/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/p2-trust-controls/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/p2-trust-controls/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/p2-trust-controls/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/p2-trust-controls/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:30:32.887225Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,74 +0,0 @@
slug: p3-launch-support-playbook
title: Launch & Support Playbook
description: Launch checklist, incident roles, communication templates — team can respond to production issues within defined SLA
phase: draft
created_at: 2026-03-03T06:31:12.118394Z
updated_at: 2026-03-03T06:31:12.118394Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/p3-launch-support-playbook/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/p3-launch-support-playbook/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/p3-launch-support-playbook/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/p3-launch-support-playbook/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/p3-launch-support-playbook/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/p3-launch-support-playbook/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/p3-launch-support-playbook/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:31:12.118394Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,74 +0,0 @@
slug: p3-quality-operations
title: Quality Operations
description: Quality floor checks, regression dashboards, and alerting — no launch-day quality regressions ship undetected
phase: draft
created_at: 2026-03-03T06:31:12.111740Z
updated_at: 2026-03-03T06:31:12.111740Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/p3-quality-operations/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/p3-quality-operations/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/p3-quality-operations/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/p3-quality-operations/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/p3-quality-operations/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/p3-quality-operations/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/p3-quality-operations/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:31:12.111740Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,74 +0,0 @@
slug: p3-reliability-slos
title: Reliability & SLOs
description: Launch SLOs defined, monitoring active, error budgets tracked — briefing generation latency and availability within targets
phase: draft
created_at: 2026-03-03T06:31:12.104755Z
updated_at: 2026-03-03T06:31:12.104755Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/p3-reliability-slos/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/p3-reliability-slos/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/p3-reliability-slos/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/p3-reliability-slos/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/p3-reliability-slos/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/p3-reliability-slos/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/p3-reliability-slos/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:31:12.104755Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,74 +0,0 @@
slug: p4-monetization-experiments
title: Monetization Experiments
description: Pricing tests, conversion funnel instrumentation — measure which monetization model converts without degrading quality metrics
phase: draft
created_at: 2026-03-03T06:31:12.151616Z
updated_at: 2026-03-03T06:31:12.151616Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/p4-monetization-experiments/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/p4-monetization-experiments/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/p4-monetization-experiments/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/p4-monetization-experiments/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/p4-monetization-experiments/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/p4-monetization-experiments/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/p4-monetization-experiments/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:31:12.151616Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,74 +0,0 @@
slug: p4-quality-safe-growth
title: Quality-Safe Growth
description: Guardrails that prevent revenue-over-quality regressions as user volume scales — D7 retention and useful-item rate monitored against growth rate
phase: draft
created_at: 2026-03-03T06:31:12.156872Z
updated_at: 2026-03-03T06:31:12.156872Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/p4-quality-safe-growth/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/p4-quality-safe-growth/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/p4-quality-safe-growth/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/p4-quality-safe-growth/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/p4-quality-safe-growth/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/p4-quality-safe-growth/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/p4-quality-safe-growth/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:31:12.156872Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,74 +0,0 @@
slug: p4-segment-expansion-plan
title: Segment Expansion Plan
description: Data-backed expansion strategy and milestone proposal for next target segment or market
phase: draft
created_at: 2026-03-03T06:31:12.162327Z
updated_at: 2026-03-03T06:31:12.162327Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/p4-segment-expansion-plan/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/p4-segment-expansion-plan/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/p4-segment-expansion-plan/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/p4-segment-expansion-plan/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/p4-segment-expansion-plan/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/p4-segment-expansion-plan/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/p4-segment-expansion-plan/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:31:12.162327Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,74 +0,0 @@
slug: pg1-baseline-comparison
title: Baseline Comparison Study
description: A/B comparison against a non-personalized baseline feed — measure click-through, completion, and return rate lift
phase: draft
created_at: 2026-03-03T06:30:08.984440Z
updated_at: 2026-03-03T06:30:08.984440Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/pg1-baseline-comparison/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/pg1-baseline-comparison/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/pg1-baseline-comparison/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/pg1-baseline-comparison/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/pg1-baseline-comparison/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/pg1-baseline-comparison/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/pg1-baseline-comparison/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:30:08.984440Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,74 +0,0 @@
slug: pg1-instrumented-metrics
title: Instrumented Metrics Pipeline
description: Signal write rates, ranking latency p50/p99, personalization staleness, and feedback-loop latency all observable in production
phase: draft
created_at: 2026-03-03T06:30:08.990467Z
updated_at: 2026-03-03T06:30:08.990467Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/pg1-instrumented-metrics/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/pg1-instrumented-metrics/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/pg1-instrumented-metrics/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/pg1-instrumented-metrics/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/pg1-instrumented-metrics/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/pg1-instrumented-metrics/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/pg1-instrumented-metrics/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:30:08.990467Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,74 +0,0 @@
slug: pg1-personalization-correctness
title: Personalization Correctness Verification
description: Verify the personalization loop (signal write → decay → ranking) is mathematically correct and updates within 100ms of signal write
phase: draft
created_at: 2026-03-03T06:30:08.977848Z
updated_at: 2026-03-03T06:30:08.977848Z
artifacts:
- artifact_type: spec
status: missing
path: .sdlc/features/pg1-personalization-correctness/spec.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: design
status: missing
path: .sdlc/features/pg1-personalization-correctness/design.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: tasks
status: missing
path: .sdlc/features/pg1-personalization-correctness/tasks.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_plan
status: missing
path: .sdlc/features/pg1-personalization-correctness/qa-plan.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: review
status: missing
path: .sdlc/features/pg1-personalization-correctness/review.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: audit
status: missing
path: .sdlc/features/pg1-personalization-correctness/audit.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
- artifact_type: qa_results
status: missing
path: .sdlc/features/pg1-personalization-correctness/qa-results.md
created_at: null
approved_at: null
rejected_at: null
rejection_reason: null
approved_by: null
tasks: []
comments: []
next_comment_seq: 0
blockers: []
phase_history:
- phase: draft
entered: 2026-03-03T06:30:08.977848Z
exited: null
dependencies: []
archived: false
schema_version: 2

View File

@ -1,204 +0,0 @@
# Engineering Guidance
Read this before any implementation, bug fix, or test action.
## North Star: Vision & Architecture
Before writing a single line of code, read:
- **`VISION.md`** — *what* we are building and *why*. Every feature, every tradeoff, every design decision must serve this vision. If a proposed change works against it, surface it before proceeding.
- **`ARCHITECTURE.md`** — *how* the system works. Components, interfaces, data flows, and sequence diagrams showing how everything fits together. Code must conform to the architecture — never silently deviate.
These are the guiding light. When in doubt about any decision, return to them first.
## 1. Build It Right
Do it the proper way — not the quick way. The correct solution is one that
will still be correct in six months. Favor proven patterns, clear
abstractions, and designs that are easy to understand and extend. Never
trade long-term correctness for short-term convenience.
## 2. Understand Bugs Before Fixing Them
Before touching a bug, trace its root cause holistically — read surrounding
code, follow the data flow, understand why it broke. Fix the cause, not the
symptom. A patch that introduces a new bug in three months is worse than
no fix.
## 3. Enterprise Quality Bar
We build enterprise-grade software. The bar is Steve Jobs: relentless
attention to detail, nothing ships that embarrasses us, correctness and
reliability are non-negotiable. If something isn't right, make it right.
## 4. Philosophy of Software Design
Follow John Ousterhout's principles: deep modules, minimal exposed
complexity, interfaces that hide implementation detail, and code readable
in isolation. Complexity is the enemy — fight it at every level.
## 5. Meaningful, Reliable, Fast Tests
Tests must earn their place. When a test breaks, choose deliberately:
- **Remove** — if it adds little value or tests implementation detail
- **Rewrite** — if it was poorly structured for the scenario
- **Refactor** — if the interface it tests changed legitimately
- **Quick-fix** — only if the fix is obvious and the test is clearly valuable
Never keep a flaky or low-value test just to preserve coverage numbers.
## 6. Using sdlc
All state lives in `.sdlc/` YAML files. **Never edit them directly** — use the CLI.
Direct edits cause deserialization failures and corrupt state.
| Action | Command |
|---|---|
| Create feature | `sdlc feature create <slug> --title "…"` |
| Get next action | `sdlc next --for <slug> --json` |
| Write artifact | Write Markdown to `output_path` from the directive |
| Submit draft | `sdlc artifact draft <slug> <type>` |
| Approve artifact | `sdlc artifact approve <slug> <type>` |
| Reject artifact | `sdlc artifact reject <slug> <type>` |
| Merge (release feature) | `sdlc merge <slug>` |
| Add task | `sdlc task add <slug> "title"` |
| Start task | `sdlc task start <slug> <task-id>` |
| Complete task | `sdlc task complete <slug> <task-id>` |
| Block task | `sdlc task block <slug> <task-id> "reason"` |
| Add comment | `sdlc comment create <slug> "body"` |
| Show feature | `sdlc feature show <slug> --json` |
| List tasks | `sdlc task list <slug>` |
| Project state | `sdlc state` |
| Survey milestone waves | `sdlc project prepare [--milestone <slug>]` |
| Mark milestone prepared | `sdlc milestone mark-prepared <slug>` |
| Project phase | `sdlc project status` |
| Escalate to human | `sdlc escalate create --kind <kind> --title "…" --context "…" [--feature <slug>]` |
| List escalations | `sdlc escalate list` |
| Resolve escalation | `sdlc escalate resolve <id> "resolution note"` |
| Knowledge base status | `sdlc knowledge status` |
| List knowledge entries | `sdlc knowledge list [--code-prefix <code>]` |
| Search knowledge base | `sdlc knowledge search <query>` |
| Show knowledge entry | `sdlc knowledge show <slug>` |
| Add knowledge entry | `sdlc knowledge add --title "..." --code <code> --content "..."` |
| Show catalog taxonomy | `sdlc knowledge catalog show` |
| Seed from workspaces | `sdlc knowledge librarian init` |
Phases advance automatically from artifact approvals — never call `sdlc feature transition`.
The only files you write directly are Markdown artifacts to `output_path`.
## 7. SDLC Tool Suite
Project-scoped TypeScript tools in `.sdlc/tools/` — callable by agents and humans during any lifecycle phase.
Read `.sdlc/tools/tools.md` for the full list, or each tool's `README.md` for detailed docs.
| Tool | Command | Purpose |
|---|---|---|
| ama | `sdlc tool run ama --setup` then `sdlc tool run ama --question "..."` | Search codebase for relevant file excerpts |
Build a custom tool: `sdlc tool scaffold <name> "<description>"`
Update the manifest after adding/changing tools: `sdlc tool sync`
## 8. Project Secrets
Encrypted secrets live in `.sdlc/secrets/`. The encrypted files (`.age`) and key
name sidecars (`.meta.yaml`) are **safe to commit**. Plain `.env.*` files must never
be committed — they are gitignored automatically.
| Action | Command |
|---|---|
| List environments | `sdlc secrets env list` |
| List key names (no decrypt) | `sdlc secrets env names <env>` |
| Load secrets into shell | `eval $(sdlc secrets env export <env>)` |
| Set a secret | `sdlc secrets env set <env> KEY=value` |
| List authorized keys | `sdlc secrets keys list` |
| Add a key | `sdlc secrets keys add --name <n> --key "$(cat ~/.ssh/id_ed25519.pub)"` |
| Rekey after key change | `sdlc secrets keys rekey` |
**For agents:** Check `sdlc secrets env names <env>` to see which variables are
available. Load the matching env before any task or build step that needs credentials:
- Feature/local work → `eval $(sdlc secrets env export development)`
- Deploy tasks → `eval $(sdlc secrets env export production)`
Never log or hardcode secret values. Reference by env var name only (e.g. `$ANTHROPIC_API_KEY`).
**In builds:** The vault is for local and agent use only. CI/CD platforms (GitHub Actions,
etc.) manage their own secrets separately — agents cannot inject into platform CI secrets.
If a build needs a credential that must live in CI, use `secret_request` escalation (§9).
## 9. Escalating to the Human
Escalations are for **actions only a human can take**. They are rare and deliberate — not a
general-purpose communication channel. Before escalating, ask: "Can I resolve this myself?"
If yes, do it. If not, escalate.
| Kind | When to escalate | Example |
|---|---|---|
| `secret_request` | Need a credential or env var that doesn't exist | "Add STRIPE_API_KEY to production env in Secrets page" |
| `question` | Strategic decision with no clear right answer | "Should checkout support crypto payments?" |
| `vision` | Product direction is undefined or contradictory | "No vision defined — what is the milestone goal?" |
| `manual_test` | Testing requires physical interaction | "Verify Google OAuth login in production browser" |
**Do NOT escalate:** code review findings, spec ambiguity you can resolve, implementation
decisions, anything an agent can handle autonomously.
**How to escalate:**
```bash
sdlc escalate create \
--kind secret_request \
--title "Need OPENAI_API_KEY in .env.production" \
--context "AI summary feature calls OpenAI in prod. Dev works with a mock. Need the real key to test end-to-end." \
--feature my-ai-feature # omit if not feature-specific
```
**After creating:** stop the current run immediately. If `--feature` was specified, the feature
is now gated by an auto-added Blocker comment. The escalation appears in the Dashboard under
**"Needs Your Attention"**. The human must act before the feature can proceed.
**The difference from `comment --flag blocker`:**
- `comment --flag blocker` — an implementation concern the next agent cycle might fix
- `sdlc escalate create` — an action only a human can perform; stop until resolved
## 10. Frontend API Calls
Never hardcode `http://localhost:PORT` in frontend code — CORS blocks cross-origin
requests in development and the address is wrong in production.
**Pattern:**
- Use a relative base URL (`/api`) in all fetch/client code
- Configure the dev server proxy (Vite `server.proxy`, Next.js `rewrites`,
webpack `devServer.proxy`) to forward `/api``http://localhost:<API_PORT>`
- In production, frontend and API share the same origin — relative paths resolve correctly
When fixing a CORS error or adding a new API client, apply this pattern instead of
adding CORS headers or introducing environment-specific URLs.
## 11. Production Safety
This is a live system with real users. Every change must leave the codebase healthier — not just correct, but cleaner.
**Migrations:** Add defensive deserialization before removing old formats. Never the reverse. Test that both old and new formats load cleanly before shipping.
**Stability hazards to avoid:**
- Infinite loops: any polling, retry, or SSE reconnect loop must have a termination condition and backoff
- Connection exhaustion: SSE subscriptions, DB connections, and broadcast channels must be bounded and cleaned up on drop
- Complex failure modes: prefer simple, flat control flow over deeply nested async chains — when it breaks at 3am, you must be able to read the trace
**Quality bar:** if a change makes the code harder to reason about, makes logs less useful, or adds a failure mode with no clear recovery path — stop and reconsider. Simpler is always better.
## 12. Project Guidelines
Before writing implementation code, check if `.sdlc/guidelines/index.yaml` exists.
If it does, read it and load any guidelines whose `scope` overlaps with the work at hand.
```bash
# Check
ls .sdlc/guidelines/index.yaml 2>/dev/null && cat .sdlc/guidelines/index.yaml
```
Guidelines contain `⚑ Rule:` statements with `✓ Good:` and `✗ Bad:` code examples derived
from this codebase. They are authoritative — if your implementation would violate a rule,
fix the approach before proceeding, not after review catches it.
If no index exists, no guidelines have been published yet. Proceed normally.

View File

@ -1,16 +0,0 @@
classes:
- code: '100'
name: Core Thesis
- code: '200'
name: Domain Model
- code: '300'
name: Module Structure
- code: '400'
name: Storage Architecture
- code: '500'
name: Signal System
- code: '600'
name: Vector Index
- code: '700'
name: Text Search
updated_at: 2026-03-03T06:23:11.759071Z

View File

@ -1,4 +0,0 @@
actions:
- timestamp: 2026-03-03T06:23:11.759775Z
action_type: harvest
detail: librarian init

View File

@ -1,8 +0,0 @@
slug: m0
title: Embeddable Runtime
vision: A developer can cargo add tidalDB, run it in-process with zero config, and get structured diagnostics — proving the embeddable runtime baseline works.
features: []
created_at: 2026-03-03T06:29:10.012810Z
updated_at: 2026-03-03T06:29:28.060717Z
released_at: 2026-03-03T06:29:28.060716Z
schema_version: 1

View File

@ -1,8 +0,0 @@
slug: m1
title: Signal Engine
vision: A developer can write engagement signals and see O(1) decay scores and windowed aggregates — proving signals are a database primitive, not application math.
features: []
created_at: 2026-03-03T06:29:10.025474Z
updated_at: 2026-03-03T06:29:28.068009Z
released_at: 2026-03-03T06:29:28.068009Z
schema_version: 1

View File

@ -1,10 +0,0 @@
slug: m10
title: Governance & Agent Rights
vision: Community rules and agent-scoped permissions control what signals influence ranking — users and communities can control exactly which signals affect their experience and revoke them safely.
features:
- m10-community-policy-engine
- m10-agent-capability-boundaries
- m10-signal-revocation-controls
created_at: 2026-03-03T06:29:45.049713Z
updated_at: 2026-03-03T06:29:45.091585Z
schema_version: 1

View File

@ -1,8 +0,0 @@
slug: m2
title: Ranked Retrieval
vision: A single RETRIEVE query retrieves, scores, and ranks content using live signals — proving one query replaces what 6 systems currently produce.
features: []
created_at: 2026-03-03T06:29:10.032247Z
updated_at: 2026-03-03T06:29:28.074556Z
released_at: 2026-03-03T06:29:28.074555Z
schema_version: 1

View File

@ -1,8 +0,0 @@
slug: m3
title: Personalized Ranking
vision: User context shapes retrieval and ranking — the 'For You' query works correctly, reflecting user preferences, relationships, and session context.
features: []
created_at: 2026-03-03T06:29:10.038502Z
updated_at: 2026-03-03T06:29:28.080975Z
released_at: 2026-03-03T06:29:28.080974Z
schema_version: 1

View File

@ -1,8 +0,0 @@
slug: m4
title: Agent Memory
vision: Agents can create sessions, write signals with policy constraints, and enforce rate limits inside tidalDB — enabling RLHF loops and conversational memory.
features: []
created_at: 2026-03-03T06:29:10.045226Z
updated_at: 2026-03-03T06:29:28.086574Z
released_at: 2026-03-03T06:29:28.086574Z
schema_version: 1

View File

@ -1,8 +0,0 @@
slug: m5
title: Hybrid Search
vision: Text + semantic + signal-ranked search in one query — BM25, ANN, and personalization fused via RRF, enabling UC-02, UC-10, UC-11.
features: []
created_at: 2026-03-03T06:29:10.052823Z
updated_at: 2026-03-03T06:29:28.091698Z
released_at: 2026-03-03T06:29:28.091698Z
schema_version: 1

View File

@ -1,8 +0,0 @@
slug: m6
title: Full Surface Coverage
vision: Every use case, every sort mode, every filter, every feedback loop is operational — UC-01 through UC-14 complete in a single database.
features: []
created_at: 2026-03-03T06:29:10.059993Z
updated_at: 2026-03-03T06:29:28.097611Z
released_at: 2026-03-03T06:29:28.097611Z
schema_version: 1

View File

@ -1,8 +0,0 @@
slug: m7
title: Production Hardening
vision: Crash safety, graceful degradation, rate limiting, and operational readiness — all UCs at production quality with diagnostics and telemetry.
features: []
created_at: 2026-03-03T06:29:10.067328Z
updated_at: 2026-03-03T06:29:28.103643Z
released_at: 2026-03-03T06:29:28.103643Z
schema_version: 1

View File

@ -1,8 +0,0 @@
slug: m8
title: Distributed Fabric
vision: Multi-region, multi-tenant WAL replication with CRDT reconciliation keeps agent-memory semantics intact across distributed deployments.
features: []
created_at: 2026-03-03T06:29:10.075155Z
updated_at: 2026-03-03T06:29:28.109493Z
released_at: 2026-03-03T06:29:28.109493Z
schema_version: 1

View File

@ -1,11 +0,0 @@
slug: m9
title: Community Sync & Revocation
vision: A user can opt their local profile into community personalization, and later leave — removing their contributions from future ranking without destroying local history.
features:
- m9-community-profile-sync
- m9-leave-revocation
- m9-retroactive-purge
- m9-purge-rematerialization
created_at: 2026-03-03T06:29:37.962436Z
updated_at: 2026-03-03T06:29:38.030047Z
schema_version: 1

View File

@ -1,10 +0,0 @@
slug: p0
title: Beachhead Validation
vision: 20-50 target users complete a 2-week concierge pilot with D2/D7 retention measured — proving the personal briefing concept is valuable enough for repeated use.
features:
- p0-target-segment-recruitment
- p0-concierge-pilot-loop
- p0-validation-readout
created_at: 2026-03-03T06:29:53.105359Z
updated_at: 2026-03-03T06:29:53.150315Z
schema_version: 1

View File

@ -1,10 +0,0 @@
slug: p1
title: Concierge Alpha
vision: Daily ranked brief is live for pilot cohort with reason labels, immediate feedback adaptation, and time-budget mode — proving 'Today Brief' usefulness and repeat behavior.
features:
- p1-briefing-ux-reason-labels
- p1-feedback-loop-ux
- p1-quality-diversity-baseline
created_at: 2026-03-03T06:30:00.885199Z
updated_at: 2026-03-03T06:30:00.932156Z
schema_version: 1

View File

@ -1,10 +0,0 @@
slug: p2
title: Productized Beta
vision: Self-serve onboarding under 3 minutes, cohort-scoped trending surfaces, and trust controls active — D7 retention and useful-item rate exceed baseline comparison feed.
features:
- p2-self-serve-onboarding
- p2-cohort-context-views
- p2-trust-controls
created_at: 2026-03-03T06:30:32.861070Z
updated_at: 2026-03-03T06:30:32.903751Z
schema_version: 1

View File

@ -1,10 +0,0 @@
slug: p3
title: Public Launch
vision: Briefing generation reliability SLOs met, quality floor enforced, support playbook active — product is ready for public users at real volume.
features:
- p3-reliability-slos
- p3-quality-operations
- p3-launch-support-playbook
created_at: 2026-03-03T06:31:12.087940Z
updated_at: 2026-03-03T06:31:12.135803Z
schema_version: 1

View File

@ -1,10 +0,0 @@
slug: p4
title: Scale & Revenue Fit
vision: Monetization model validated, quality-safe growth guardrails active, next expansion segment chosen with evidence — sustainable unit economics proven at scale.
features:
- p4-monetization-experiments
- p4-quality-safe-growth
- p4-segment-expansion-plan
created_at: 2026-03-03T06:31:12.140928Z
updated_at: 2026-03-03T06:31:12.178883Z
schema_version: 1

View File

@ -1,10 +0,0 @@
slug: pg1
title: Personalization Core Done Gate
vision: Core personalization loop is correct, immediate, and measurably better than baseline — gate that must pass before P2 Productized Beta proceeds.
features:
- pg1-personalization-correctness
- pg1-baseline-comparison
- pg1-instrumented-metrics
created_at: 2026-03-03T06:30:08.963318Z
updated_at: 2026-03-03T06:30:09.007191Z
schema_version: 1

View File

@ -1,51 +0,0 @@
version: 1
project: tidalDB
active_features:
- m9-community-profile-sync
- m9-leave-revocation
- m9-retroactive-purge
- m9-purge-rematerialization
- m10-community-policy-engine
- m10-agent-capability-boundaries
- m10-signal-revocation-controls
- p0-target-segment-recruitment
- p0-concierge-pilot-loop
- p0-validation-readout
- p1-briefing-ux-reason-labels
- p1-feedback-loop-ux
- p1-quality-diversity-baseline
- pg1-personalization-correctness
- pg1-baseline-comparison
- pg1-instrumented-metrics
- p2-self-serve-onboarding
- p2-cohort-context-views
- p2-trust-controls
- p3-reliability-slos
- p3-quality-operations
- p3-launch-support-playbook
- p4-monetization-experiments
- p4-quality-safe-growth
- p4-segment-expansion-plan
active_directives: []
history: []
blocked: []
milestones:
- m0
- m1
- m2
- m3
- m4
- m5
- m6
- m7
- m8
- m9
- m10
- p0
- p1
- pg1
- p2
- p3
- p4
active_ponders: []
last_updated: 2026-03-03T06:31:12.162637Z

View File

@ -1,44 +0,0 @@
/**
* SDLC Tool Config Loader
*
* Reads .sdlc/tools/<name>/config.yaml. If the file is missing or unparseable,
* returns the provided defaults tools should never hard-fail on missing config.
*
* Supports flat key: value YAML only. Arrays and nested objects are intentionally
* not supported keep tool configs simple scalars.
*/
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
export function loadToolConfig<T extends Record<string, unknown>>(
root: string,
toolName: string,
defaults: T,
): T {
const configPath = join(root, '.sdlc', 'tools', toolName, 'config.yaml')
try {
const raw = readFileSync(configPath, 'utf8')
const parsed = parseSimpleYaml(raw)
return { ...defaults, ...parsed } as T
} catch {
return defaults
}
}
/** Parse a flat key: value YAML file. Skips blank lines, comments, and array items. */
function parseSimpleYaml(content: string): Record<string, unknown> {
const result: Record<string, unknown> = {}
for (const line of content.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('-')) continue
const colonIdx = trimmed.indexOf(':')
if (colonIdx === -1) continue
const key = trimmed.slice(0, colonIdx).trim()
const rawValue = trimmed.slice(colonIdx + 1).trim()
if (!key || !rawValue) continue
const value = rawValue.replace(/^["'](.*)["']$/, '$1')
const num = Number(value)
result[key] = Number.isNaN(num) ? value : num
}
return result
}

View File

@ -1,25 +0,0 @@
/**
* Standard SDLC Tool Logger
*
* Writes structured log lines to STDERR (never stdout stdout is reserved
* for JSON output). Use this in every tool to produce consistent, parseable logs.
*
* Format: [sdlc-tool:<name>] LEVEL: message
* Example: [sdlc-tool:ama] INFO: Indexed 312 files in 842ms
*
* Set SDLC_TOOL_DEBUG=1 to enable debug-level output.
*/
export function makeLogger(toolName: string) {
const prefix = `[sdlc-tool:${toolName}]`
return {
info: (msg: string) => console.error(`${prefix} INFO: ${msg}`),
warn: (msg: string) => console.error(`${prefix} WARN: ${msg}`),
error: (msg: string) => console.error(`${prefix} ERROR: ${msg}`),
debug: (msg: string) => {
if (process.env.SDLC_TOOL_DEBUG) console.error(`${prefix} DEBUG: ${msg}`)
},
}
}
export type Logger = ReturnType<typeof makeLogger>

View File

@ -1,68 +0,0 @@
/**
* Cross-runtime helpers for Bun, Deno, and Node.
*
* Normalizes: argv access, stdin reading, env access, and process exit
* across the three supported runtimes.
*
* Detection: checks for globalThis.Deno to identify Deno; falls back
* to process (Node.js / Bun).
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
/** Returns command-line arguments after the script name (process.argv[2+]). */
export function getArgs(): string[] {
if (typeof (globalThis as any).Deno !== 'undefined') {
return [...(globalThis as any).Deno.args]
}
return process.argv.slice(2)
}
/** Read all of stdin as a UTF-8 string. Returns empty string if stdin is a TTY or closed. */
export async function readStdin(): Promise<string> {
if (typeof (globalThis as any).Deno !== 'undefined') {
const chunks: Uint8Array[] = []
const reader = (globalThis as any).Deno.stdin.readable.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
}
} finally {
reader.releaseLock()
}
const total = chunks.reduce((sum: number, c: Uint8Array) => sum + c.length, 0)
const merged = new Uint8Array(total)
let offset = 0
for (const chunk of chunks) {
merged.set(chunk, offset)
offset += chunk.length
}
return new TextDecoder().decode(merged)
}
// Node.js / Bun
if ((process.stdin as any).isTTY) return ''
const chunks: Buffer[] = []
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
return Buffer.concat(chunks).toString('utf8')
}
/** Get a process environment variable. Works across Bun, Deno, and Node. */
export function getEnv(key: string): string | undefined {
if (typeof (globalThis as any).Deno !== 'undefined') {
return (globalThis as any).Deno.env.get(key)
}
return process.env[key]
}
/** Exit the process with the given code. */
export function exit(code: number): never {
if (typeof (globalThis as any).Deno !== 'undefined') {
;(globalThis as any).Deno.exit(code)
}
process.exit(code)
throw new Error('unreachable')
}

View File

@ -1,47 +0,0 @@
/**
* SDLC Tool Shared Interface
*
* Every SDLC tool imports from this file. It defines the full type contract
* that tools must satisfy. Do not change the shape of these types without
* updating all core tools and regenerating tools.md.
*
* Tool protocol (stdin/stdout):
* - --meta No stdin. Writes ToolMeta JSON to stdout.
* - --run Reads JSON from stdin. Writes ToolResult JSON to stdout. Exit 0 ok, 1 error.
* - --setup No stdin. Writes ToolResult JSON to stdout. Exit 0 ok, 1 error.
*
* All log output goes to STDERR. STDOUT is reserved for JSON only.
*/
/** Metadata describing a tool — returned by --meta mode. */
export interface ToolMeta {
/** Matches the directory name exactly (e.g. "ama", "quality-check") */
name: string
/** Human-readable title shown in the tools list */
display_name: string
/** One sentence, present tense, no trailing period */
description: string
/** Semver, mirrors sdlc binary version at install time */
version: string
/** JSON Schema describing valid input for --run */
input_schema: JsonSchema
/** JSON Schema describing the data field in ToolResult */
output_schema: JsonSchema
/** True if --setup must run before first --run */
requires_setup: boolean
/** One sentence describing what setup does (required if requires_setup = true) */
setup_description?: string
}
/** The result envelope returned by --run and --setup modes. */
export interface ToolResult<T = unknown> {
ok: boolean
data?: T
/** Present only when ok = false */
error?: string
/** Wall-clock milliseconds for the operation */
duration_ms?: number
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type JsonSchema = Record<string, any>

View File

@ -1,36 +0,0 @@
# AMA — Ask Me Anything
Answers questions about the codebase by searching a pre-built keyword index.
## Setup (run once)
```bash
sdlc tool run ama --setup
```
## Usage
```bash
sdlc tool run ama --question "where is JWT validation?"
sdlc tool run ama --question "how does feature transition work?"
```
## How it works
1. `--setup` walks source files, chunks them into 40-line windows, extracts keyword tokens,
and writes `.sdlc/tools/ama/index/chunks.json`
2. `--run` scores chunks by keyword overlap with your question, returns top file excerpts
3. Your AI assistant reads the excerpts and synthesizes an answer
## Configuration
Edit `.sdlc/tools/ama/config.yaml` to change which file extensions are indexed
or to adjust chunk size, overlap, and result count.
## Index location
`.sdlc/tools/ama/index/chunks.json` — gitignored, regenerate with `--setup`
## Re-index when needed
Re-run `--setup` after significant file changes. It's fast and safe to run any time.

View File

@ -1,18 +0,0 @@
name: ama
version: 0.1.0
description: Answers questions about the codebase using a pre-built keyword index
# File extensions to include in the index (comma-separated)
extensions: .ts,.js,.tsx,.jsx,.rs,.go,.py,.rb,.java,.md,.txt,.yaml,.yml,.toml
# Number of lines per chunk
chunk_lines: 40
# Lines of overlap between consecutive chunks (reduces missed context at boundaries)
chunk_overlap: 5
# Maximum results to return per query
max_results: 5
# Skip files larger than this size (kilobytes)
max_file_kb: 500

View File

@ -1,506 +0,0 @@
/**
* AMA Ask Me Anything
* =====================
* Answers questions about the codebase by searching a pre-built keyword index.
*
* WHAT IT DOES
* ------------
* --setup: Walks all source files matching configured extensions. On first run,
* indexes every file. On subsequent runs, skips unchanged files (mtime
* check), re-indexes changed/new files, and prunes deleted files.
* Writes chunks.json (TF-IDF index) and last_indexed.json (mtime map).
* Re-running --setup is always safe (incremental or full).
*
* --run: Reads JSON from stdin: { "question": "string" }
* Loads the TF-IDF index, scores chunks by IDF-weighted keyword overlap,
* returns top results as source excerpts with relevance scores.
* Sources from files changed since last indexing are flagged stale.
*
* --meta: Writes ToolMeta JSON to stdout. Used by `sdlc tool sync`.
*
* WHAT IT READS
* -------------
* - .sdlc/tools/ama/config.yaml (extensions, chunk settings)
* - .sdlc/tools/ama/index/chunks.json (built by --setup)
* - .sdlc/tools/ama/index/last_indexed.json (mtime map; built by --setup)
* - Source files matching config.extensions (during --setup only)
*
* WHAT IT WRITES
* --------------
* - .sdlc/tools/ama/index/chunks.json (during --setup; TF-IDF index)
* - .sdlc/tools/ama/index/last_indexed.json (during --setup; mtime map for incremental re-runs)
* - STDERR: structured log lines via _shared/log.ts
* - STDOUT: JSON only (ToolResult shape from _shared/types.ts)
*
* EXTENDING
* ---------
* Replace scoreChunks() with embedding-based cosine similarity to improve answer
* quality. The rest of the pipeline (chunking, index format, protocol) stays the same.
*
* For LLM synthesis: call the Claude API in run() with the top excerpts as context.
* Add "synthesis_model" to config.yaml to control which model is used.
*/
import type { ToolMeta, ToolResult } from '../_shared/types.ts'
import { makeLogger } from '../_shared/log.ts'
import { loadToolConfig } from '../_shared/config.ts'
import { getArgs, readStdin, exit } from '../_shared/runtime.ts'
import {
readdirSync, readFileSync, writeFileSync, mkdirSync, statSync, existsSync,
} from 'node:fs'
import { join, extname, relative } from 'node:path'
const log = makeLogger('ama')
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
interface AmaConfig {
chunk_lines: number
chunk_overlap: number
max_results: number
max_file_kb: number
extensions: string
}
const DEFAULT_CONFIG: AmaConfig = {
chunk_lines: 40,
chunk_overlap: 5,
max_results: 5,
max_file_kb: 500,
extensions: '.ts,.js,.tsx,.jsx,.rs,.go,.py,.rb,.java,.md,.txt,.yaml,.yml,.toml',
}
// ---------------------------------------------------------------------------
// Tool metadata
// ---------------------------------------------------------------------------
export const meta: ToolMeta = {
name: 'ama',
display_name: 'AMA — Ask Me Anything',
description: 'Answers questions about the codebase using a pre-built TF-IDF keyword index',
version: '0.2.1',
requires_setup: true,
setup_description: 'Indexes source files for keyword search (first run is full index; subsequent runs are incremental)',
input_schema: {
type: 'object',
required: ['question'],
properties: {
question: { type: 'string', description: 'The question to answer about the codebase' },
},
},
output_schema: {
type: 'object',
properties: {
sources: {
type: 'array',
items: {
type: 'object',
properties: {
path: { type: 'string' },
lines: { type: 'array', items: { type: 'number' }, minItems: 2, maxItems: 2 },
excerpt: { type: 'string' },
score: { type: 'number', description: 'TF-IDF relevance score (0.01.0)' },
stale: { type: 'boolean', description: 'True if the source file changed since last index run' },
},
},
},
},
},
}
// ---------------------------------------------------------------------------
// Index types
// ---------------------------------------------------------------------------
interface Chunk {
path: string
start: number
end: number
tokens: string[]
}
interface Index {
version: number
generated: string
chunks: Chunk[]
idf: Record<string, number>
}
interface MtimeMap {
version: number
indexed_at: string
files: Record<string, number>
}
interface AmaSource {
path: string
lines: [number, number]
excerpt: string
score: number
stale?: boolean
}
interface AmaOutput {
sources: AmaSource[]
}
// ---------------------------------------------------------------------------
// Setup — build the keyword index
// ---------------------------------------------------------------------------
export async function setup(root: string): Promise<ToolResult<{
files_indexed: number
files_skipped: number
files_pruned: number
chunks_written: number
total_chunks: number
duration_ms: number
index_size_kb: number
}>> {
const start = Date.now()
const config = loadToolConfig(root, 'ama', DEFAULT_CONFIG)
const extensions = new Set(
String(config.extensions).split(',').map(e => e.trim()).filter(Boolean),
)
const indexDir = join(root, '.sdlc', 'tools', 'ama', 'index')
mkdirSync(indexDir, { recursive: true })
const chunksPath = join(indexDir, 'chunks.json')
const mtimePath = join(indexDir, 'last_indexed.json')
// Load previous index and mtime map for incremental re-indexing
let prevChunks: Chunk[] = []
let prevMtimes: Record<string, number> = {}
const isIncremental = existsSync(chunksPath) && existsSync(mtimePath)
if (isIncremental) {
try {
const prevIndex = JSON.parse(readFileSync(chunksPath, 'utf8')) as Index
prevChunks = prevIndex.chunks ?? []
const mtimeData = JSON.parse(readFileSync(mtimePath, 'utf8')) as MtimeMap
prevMtimes = mtimeData.files ?? {}
log.info(`incremental mode: ${prevChunks.length} existing chunks, ${Object.keys(prevMtimes).length} tracked files`)
} catch {
log.warn('could not load previous index — falling back to full re-index')
prevChunks = []
prevMtimes = {}
}
} else {
log.info('full index mode (no previous index found)')
}
log.info(`indexing with extensions: ${[...extensions].join(', ')}`)
const allFiles = walkFiles(root, extensions, Number(config.max_file_kb))
log.info(`found ${allFiles.length} files to consider`)
// Group previous chunks by file for efficient lookup
const prevChunksByFile = new Map<string, Chunk[]>()
for (const chunk of prevChunks) {
const arr = prevChunksByFile.get(chunk.path) ?? []
arr.push(chunk)
prevChunksByFile.set(chunk.path, arr)
}
const newMtimes: Record<string, number> = {}
const unchangedChunks: Chunk[] = []
const freshChunks: Chunk[] = []
let filesSkipped = 0
let filesIndexed = 0
for (const filePath of allFiles) {
const relPath = relative(root, filePath)
const mtime = statSync(filePath).mtimeMs
if (isIncremental && prevMtimes[relPath] === mtime) {
unchangedChunks.push(...(prevChunksByFile.get(relPath) ?? []))
newMtimes[relPath] = mtime
filesSkipped++
} else {
try {
const content = readFileSync(filePath, 'utf8')
const fileChunks = chunkFile(relPath, content, Number(config.chunk_lines), Number(config.chunk_overlap))
freshChunks.push(...fileChunks)
newMtimes[relPath] = mtime
filesIndexed++
} catch (e) {
log.warn(`skipping ${relPath}: ${e}`)
}
}
}
// Count pruned files (tracked before but no longer on disk)
const currentPaths = new Set(allFiles.map(f => relative(root, f)))
const filesPruned = Object.keys(prevMtimes).filter(p => !currentPaths.has(p)).length
if (filesPruned > 0) log.info(`pruned ${filesPruned} deleted/moved file(s) from index`)
const allChunks = [...unchangedChunks, ...freshChunks]
log.info(`${filesIndexed} indexed, ${filesSkipped} skipped, ${filesPruned} pruned — ${allChunks.length} total chunks`)
// Compute smoothed IDF: log((N+1)/(df+1)) + 1 for each term
const N = allChunks.length
const df: Record<string, number> = {}
for (const chunk of allChunks) {
for (const token of chunk.tokens) {
df[token] = (df[token] ?? 0) + 1
}
}
const idf: Record<string, number> = {}
for (const [term, freq] of Object.entries(df)) {
idf[term] = Math.log((N + 1) / (freq + 1)) + 1
}
// Write index and mtime map
const index: Index = { version: 2, generated: new Date().toISOString(), chunks: allChunks, idf }
const indexJson = JSON.stringify(index)
writeFileSync(chunksPath, indexJson)
const mtimeMap: MtimeMap = { version: 1, indexed_at: new Date().toISOString(), files: newMtimes }
writeFileSync(mtimePath, JSON.stringify(mtimeMap))
const duration_ms = Date.now() - start
const index_size_kb = Math.round(indexJson.length / 1024)
log.info(`done in ${duration_ms}ms — index size: ${index_size_kb}KB`)
return {
ok: true,
data: {
files_indexed: filesIndexed,
files_skipped: filesSkipped,
files_pruned: filesPruned,
chunks_written: freshChunks.length,
total_chunks: allChunks.length,
duration_ms,
index_size_kb,
},
duration_ms,
}
}
// ---------------------------------------------------------------------------
// Run — answer a question using the index
// ---------------------------------------------------------------------------
export async function run(
input: { question?: string },
root: string,
): Promise<ToolResult<AmaOutput>> {
const start = Date.now()
const config = loadToolConfig(root, 'ama', DEFAULT_CONFIG)
const question = input.question?.trim()
if (!question) {
return { ok: false, error: 'input.question is required' }
}
const indexPath = join(root, '.sdlc', 'tools', 'ama', 'index', 'chunks.json')
if (!existsSync(indexPath)) {
return {
ok: false,
error: 'Index not built. Run setup first: sdlc tool run ama --setup',
}
}
let index: Index
try {
index = JSON.parse(readFileSync(indexPath, 'utf8')) as Index
} catch (e) {
return { ok: false, error: `Failed to load index: ${e}. Re-run: sdlc tool run ama --setup` }
}
// Load mtime map for stale source detection (non-fatal if absent)
let mtimes: Record<string, number> = {}
try {
const mtimePath = join(root, '.sdlc', 'tools', 'ama', 'index', 'last_indexed.json')
if (existsSync(mtimePath)) {
mtimes = (JSON.parse(readFileSync(mtimePath, 'utf8')) as MtimeMap).files ?? {}
}
} catch { /* stale detection skipped */ }
log.info(`scoring ${index.chunks.length} chunks for: "${question}"`)
// idf falls back gracefully to 1.0 weights for v1 indexes without IDF
const idf = index.idf ?? {}
const topChunks = scoreChunks(question, index.chunks, idf).slice(0, Number(config.max_results))
const sources: AmaSource[] = []
for (const { chunk, score } of topChunks) {
const fullPath = join(root, chunk.path)
try {
const lines = readFileSync(fullPath, 'utf8').split('\n')
const excerpt = lines.slice(chunk.start - 1, chunk.end).join('\n')
// Stale detection: flag if file changed since last index run
let stale = false
try {
if (mtimes[chunk.path] !== undefined && statSync(fullPath).mtimeMs !== mtimes[chunk.path]) {
stale = true
log.warn(`stale source: ${chunk.path} changed since last index run`)
}
} catch { /* file may not exist — handled above */ }
const source: AmaSource = { path: chunk.path, lines: [chunk.start, chunk.end], excerpt, score }
if (stale) source.stale = true
sources.push(source)
} catch {
log.warn(`skipping deleted/moved file: ${chunk.path}`)
}
}
const duration_ms = Date.now() - start
log.info(`returned ${sources.length} sources in ${duration_ms}ms`)
return { ok: true, data: { sources }, duration_ms }
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
const SKIP_DIRS = new Set([
'node_modules', '.git', 'target', 'dist', 'build', '.sdlc',
'.next', '__pycache__', '.cache', 'coverage',
])
function walkFiles(root: string, extensions: Set<string>, maxFileKb: number): string[] {
const results: string[] = []
function walk(dir: string) {
let entries: ReturnType<typeof readdirSync>
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (entry.name.startsWith('.')) continue
const full = join(dir, entry.name)
if (entry.isDirectory()) {
if (!SKIP_DIRS.has(entry.name)) walk(full)
} else if (entry.isFile()) {
if (!extensions.has(extname(entry.name))) continue
try {
if (statSync(full).size > maxFileKb * 1024) {
log.warn(`skipping large file (${Math.round(statSync(full).size / 1024)}KB): ${relative(root, full)}`)
continue
}
} catch {
continue
}
results.push(full)
}
}
}
walk(root)
return results
}
function chunkFile(
relPath: string,
content: string,
chunkLines: number,
overlap: number,
): Chunk[] {
const lines = content.split('\n')
const chunks: Chunk[] = []
const step = Math.max(1, chunkLines - overlap)
for (let i = 0; i < lines.length; i += step) {
const start = i + 1 // 1-based line numbers
const end = Math.min(i + chunkLines, lines.length)
const tokens = extractTokens(lines.slice(i, end).join(' '))
if (tokens.length > 0) {
chunks.push({ path: relPath, start, end, tokens })
}
if (end >= lines.length) break
}
return chunks
}
/**
* Extract lowercase tokens from text, splitting on camelCase and snake_case
* boundaries to enable code-aware search. Words < 4 chars are omitted as noise.
*
* Examples:
* featureTransition ['feature', 'transition']
* SdlcError ['sdlc', 'error']
* auth_token ['auth', 'token']
* authenticate ['authenticate']
*/
function extractTokens(text: string): string[] {
// Split on camelCase and acronym boundaries before lowercasing
const expanded = text
.replace(/([a-z])([A-Z])/g, '$1 $2') // camelCase → camel Case
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // XMLParser → XML Parser
const seen = new Set<string>()
const tokens: string[] = []
for (const word of expanded.toLowerCase().split(/[^a-z0-9]+/)) {
if (word.length >= 3 && !seen.has(word)) {
seen.add(word)
tokens.push(word)
}
}
return tokens
}
/**
* Score chunks using TF-IDF weighted overlap.
* IDF is precomputed at index time (stored in chunks.json v2+).
* Falls back to uniform weights (raw overlap) for v1 indexes without IDF.
*/
function scoreChunks(
question: string,
chunks: Chunk[],
idf: Record<string, number>,
): { chunk: Chunk; score: number }[] {
const queryTokens = extractTokens(question)
if (queryTokens.length === 0) return []
const hasIdf = Object.keys(idf).length > 0
const results: { chunk: Chunk; score: number }[] = []
for (const chunk of chunks) {
const chunkSet = new Set(chunk.tokens)
let score = 0
let totalWeight = 0
for (const token of queryTokens) {
const weight = hasIdf ? (idf[token] ?? 1.0) : 1.0
totalWeight += weight
if (chunkSet.has(token)) score += weight
}
if (score > 0) {
results.push({ chunk, score: totalWeight > 0 ? score / totalWeight : 0 })
}
}
return results.sort((a, b) => b.score - a.score)
}
// ---------------------------------------------------------------------------
// CLI entrypoint
// ---------------------------------------------------------------------------
const mode = getArgs()[0] ?? '--run'
const root = process.env.SDLC_ROOT ?? process.cwd()
if (mode === '--meta') {
console.log(JSON.stringify(meta))
exit(0)
} else if (mode === '--setup') {
setup(root)
.then(result => { console.log(JSON.stringify(result)); exit(result.ok ? 0 : 1) })
.catch(e => { console.log(JSON.stringify({ ok: false, error: String(e) })); exit(1) })
} else if (mode === '--run') {
readStdin()
.then(raw => run(JSON.parse(raw || '{}') as { question?: string }, root))
.then(result => { console.log(JSON.stringify(result)); exit(result.ok ? 0 : 1) })
.catch(e => { console.log(JSON.stringify({ ok: false, error: String(e) })); exit(1) })
} else {
console.error(`Unknown mode: ${mode}. Use --meta, --setup, or --run.`)
exit(1)
}

View File

@ -1,169 +0,0 @@
# dev-driver
A stock sdlc tool that reads project state, finds the single most important next
development action, dispatches it asynchronously, and exits. Paired with a recurring
orchestrator action (every 4 hours), it makes your sdlc project self-advancing.
---
## What it does
On each invocation, dev-driver applies a 5-level priority waterfall and takes exactly one action:
1. **Flight lock** — if a previous dispatch is still in flight (< 2h), do nothing
2. **Quality check** — if `quality-check` reports failures, do nothing (fix quality first)
3. **Feature advancement** — if any feature has an active directive, advance it one step
4. **Wave start** — if a milestone has all features PLANNED/READY, start the wave
5. **Idle** — nothing actionable, exit cleanly
One action per tick. The 4-hour recurrence IS the iteration rhythm.
---
## Default action recipe
Create this action once in the sdlc UI or via CLI:
```
Label: dev-driver
Tool: dev-driver
Input: {}
Recurrence: 14400 (4 hours in seconds)
```
Then run `sdlc ui --run-actions` to enable the orchestrator.
---
## Priority waterfall (detail)
### Level 1: Flight lock
Reads `.sdlc/.dev-driver.lock`. If the lock exists and is less than 2 hours old,
exits immediately with `{ action: "waiting", lock_age_mins: N }`.
This prevents double-dispatch when a previous Claude agent is still running.
### Level 2: Quality check
Runs the `quality-check` tool. If any checks fail, exits with:
```json
{ "action": "quality_failing", "failed_checks": ["test", "lint"] }
```
Fix the failing checks before dev-driver will advance features.
### Level 3: Feature advancement
Finds features in `implementation`, `review`, `audit`, or `qa` phase with a
pending directive. Picks the first one alphabetically. Dispatches:
```bash
claude --print "/sdlc-next <slug>"
```
**This is `/sdlc-next` — one step only.** The 4-hour recurrence advances the feature
step by step over time. This is intentional: it keeps you in control and lets you
course-correct between steps.
Returns:
```json
{ "action": "feature_advanced", "slug": "my-feature", "phase": "implementation", "directive": "/sdlc-next my-feature" }
```
### Level 4: Wave start
If no features have active directives but a milestone has all features in PLANNED or READY
phase, starts the next wave:
```bash
claude --print "/sdlc-run-wave <milestone>"
```
Returns:
```json
{ "action": "wave_started", "milestone": "v21-dev-driver" }
```
### Level 5: Idle
No actionable work found. Returns:
```json
{ "action": "idle", "reason": "no actionable work found" }
```
---
## How to skip a feature
If you don't want dev-driver to autonomously advance a specific feature, add a task
with `skip:autonomous` in the title:
```bash
sdlc task add <slug> --title "skip:autonomous: needs human review before proceeding"
```
Dev-driver will exclude this feature from Level 3 selection until the task is removed
or marked done. You retain full control over which features advance autonomously.
---
## One step, not full run
Dev-driver dispatches `/sdlc-next <slug>`, NOT `/sdlc-run <slug>`.
`/sdlc-next` executes exactly one directive (write a spec, approve a design, implement
a task, etc.) and exits. The next tick, dev-driver will pick the same feature again
and advance it one more step.
This means:
- Each tick = one atomic state machine step
- You can review after each step in the Actions page
- No surprise full-feature runs that take hours
---
## Lock file
Path: `.sdlc/.dev-driver.lock`
TTL: 2 hours
```json
{
"started_at": "2026-03-02T04:00:00.000Z",
"action": "feature_advanced",
"slug": "my-feature",
"pid": 12345
}
```
The lock is written before each dispatch and cleared automatically after 2 hours.
You can delete it manually if you need to run dev-driver before the TTL expires.
---
## Output reference
All five possible outputs:
```json
// Level 1 (lock)
{ "action": "waiting", "lock_age_mins": 45 }
// Level 1 (active run)
{ "action": "waiting", "reason": "agent run in progress" }
// Level 2
{ "action": "quality_failing", "failed_checks": ["test", "clippy"] }
// Level 3
{ "action": "feature_advanced", "slug": "my-feature", "phase": "implementation", "directive": "/sdlc-next my-feature" }
// Level 4
{ "action": "wave_started", "milestone": "v21-dev-driver" }
// Level 5
{ "action": "idle", "reason": "no actionable work found" }
```
All wrapped in: `{ "ok": true, "data": { ... }, "duration_ms": N }`

View File

@ -1,447 +0,0 @@
/**
* Dev Driver
* ==========
* Finds the single most important next development action and dispatches it
* asynchronously. Designed to run on a schedule (e.g. every 4 hours) via the
* sdlc orchestrator to make development self-advancing.
*
* WHAT IT DOES
* ------------
* --run: Reads JSON from stdin: {} (no parameters)
* Applies a 5-level priority waterfall:
* 1. Flight lock if .sdlc/.dev-driver.lock < 2h old, exit waiting
* 2. Quality if quality-check fails, exit quality_failing
* 3. Features if features have active directives, dispatch /sdlc-next
* 4. Wave if a milestone wave is ready, dispatch /sdlc-run-wave
* 5. Idle nothing to do, exit idle
* Returns ToolResult<DevDriverOutput>.
*
* --meta: Writes ToolMeta JSON to stdout. Used by `sdlc tool sync`.
*
* KEY INVARIANT
* -------------
* Level 3 dispatches /sdlc-next (one step), NOT /sdlc-run (to completion).
* The 4h recurrence IS the iteration rhythm. Each tick advances exactly one
* feature by one directive. This keeps the developer in control.
*
* HOW TO SKIP A FEATURE
* ---------------------
* Add a task to the feature with "skip:autonomous" in the title:
* sdlc task add <slug> --title "skip:autonomous: needs human review"
* The dev-driver will exclude this feature from Level 3 until the task is removed.
*
* LOCK FILE
* ---------
* Path: .sdlc/.dev-driver.lock
* Written before each dispatch. TTL: 2 hours. Format:
* { started_at: ISO, action: string, slug?: string, milestone?: string, pid: number }
*/
import type { ToolMeta, ToolResult } from '../_shared/types.ts'
import { makeLogger } from '../_shared/log.ts'
import { getArgs, readStdin, exit } from '../_shared/runtime.ts'
import { execSync, spawn } from 'node:child_process'
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import { join } from 'node:path'
const log = makeLogger('dev-driver')
// ---------------------------------------------------------------------------
// Tool metadata
// ---------------------------------------------------------------------------
export const meta: ToolMeta = {
name: 'dev-driver',
display_name: 'Dev Driver',
description: 'Finds the next development action and dispatches it — advances the project one step per tick',
version: '1.0.0',
requires_setup: false,
input_schema: {
type: 'object',
properties: {},
additionalProperties: false,
},
output_schema: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['waiting', 'quality_failing', 'feature_advanced', 'wave_started', 'idle'],
description: 'What the dev-driver decided to do',
},
lock_age_mins: {
type: 'number',
description: 'Age of the flight lock in minutes (present when action=waiting from lock)',
},
reason: {
type: 'string',
description: 'Human-readable reason (present when action=waiting or idle)',
},
failed_checks: {
type: 'array',
items: { type: 'string' },
description: 'Names of failed quality checks (present when action=quality_failing)',
},
slug: {
type: 'string',
description: 'Feature slug that was advanced (present when action=feature_advanced)',
},
phase: {
type: 'string',
description: 'Current phase of the feature (present when action=feature_advanced)',
},
directive: {
type: 'string',
description: 'The /sdlc-next command that was dispatched (present when action=feature_advanced)',
},
milestone: {
type: 'string',
description: 'Milestone slug that started (present when action=wave_started)',
},
},
required: ['action'],
},
}
// ---------------------------------------------------------------------------
// Output types
// ---------------------------------------------------------------------------
type DevDriverOutput =
| { action: 'waiting'; lock_age_mins: number }
| { action: 'waiting'; reason: string }
| { action: 'quality_failing'; failed_checks: string[] }
| { action: 'feature_advanced'; slug: string; phase: string; directive: string }
| { action: 'wave_started'; milestone: string }
| { action: 'idle'; reason: string }
// ---------------------------------------------------------------------------
// Lock file (T2)
// ---------------------------------------------------------------------------
interface LockFile {
started_at: string
action: string
slug?: string
milestone?: string
pid: number
}
const LOCK_TTL_MINS = 120
function lockPath(root: string): string {
return join(root, '.sdlc', '.dev-driver.lock')
}
function readLock(root: string): LockFile | null {
const p = lockPath(root)
if (!existsSync(p)) return null
try {
return JSON.parse(readFileSync(p, 'utf8')) as LockFile
} catch {
return null
}
}
function isLockActive(lock: LockFile): boolean {
const ageMs = Date.now() - Date.parse(lock.started_at)
return ageMs < LOCK_TTL_MINS * 60 * 1000
}
function lockAgeMins(lock: LockFile): number {
return Math.floor((Date.now() - Date.parse(lock.started_at)) / 60000)
}
function writeLock(root: string, payload: Omit<LockFile, 'pid'> & { pid: number }): void {
writeFileSync(lockPath(root), JSON.stringify(payload, null, 2), 'utf8')
}
// ---------------------------------------------------------------------------
// Quality check (T3 - Level 2)
// ---------------------------------------------------------------------------
interface QCCheck {
name: string
status: 'passed' | 'failed'
}
interface QCResult {
passed: number
failed: number
checks: QCCheck[]
}
function runQualityCheck(root: string): { failed: number; failedNames: string[] } {
const toolPath = join(root, '.sdlc', 'tools', 'quality-check', 'tool.ts')
if (!existsSync(toolPath)) {
log.warn('quality-check tool not found — skipping quality gate')
return { failed: 0, failedNames: [] }
}
try {
const raw = execSync(`node ${toolPath} --run`, {
input: '{}',
encoding: 'utf8',
cwd: root,
timeout: 120_000,
stdio: ['pipe', 'pipe', 'pipe'],
})
const result = JSON.parse(raw) as ToolResult<QCResult>
if (!result.data) return { failed: 0, failedNames: [] }
const failedNames = result.data.checks
.filter(c => c.status === 'failed')
.map(c => c.name)
return { failed: result.data.failed, failedNames }
} catch (e) {
log.warn(`quality-check execution error: ${e} — treating as no failures`)
return { failed: 0, failedNames: [] }
}
}
// ---------------------------------------------------------------------------
// Active run check (T8)
// ---------------------------------------------------------------------------
function hasActiveRuns(root: string): boolean {
try {
const raw = execSync('sdlc run list --status running --json', {
encoding: 'utf8',
cwd: root,
timeout: 10_000,
})
const runs = JSON.parse(raw)
return Array.isArray(runs) && runs.length > 0
} catch {
// sdlc run list may not exist yet — skip this check gracefully
log.warn('sdlc run list not available — skipping active run check')
return false
}
}
// ---------------------------------------------------------------------------
// Feature selection (T3 - Level 3, T7, T9, T10)
// ---------------------------------------------------------------------------
interface FeatureDirective {
feature: string
current_phase: string
action: string
next_command: string
}
const ACTIVE_PHASES = new Set(['implementation', 'review', 'audit', 'qa'])
function hasSkipTag(slug: string, root: string): boolean {
const tasksPath = join(root, '.sdlc', 'features', slug, 'tasks.md')
if (!existsSync(tasksPath)) return false
try {
const content = readFileSync(tasksPath, 'utf8')
return /skip:autonomous/i.test(content)
} catch {
return false
}
}
function findActionableFeature(root: string): FeatureDirective | null {
try {
const raw = execSync('sdlc next --json', {
encoding: 'utf8',
cwd: root,
timeout: 30_000,
})
const all = JSON.parse(raw) as FeatureDirective[]
const actionable = all
.filter(d => d.action !== 'done')
.filter(d => ACTIVE_PHASES.has(d.current_phase))
.filter(d => !hasSkipTag(d.feature, root))
.sort((a, b) => a.feature.localeCompare(b.feature))
return actionable[0] ?? null
} catch (e) {
log.warn(`sdlc next --json failed: ${e}`)
return null
}
}
// ---------------------------------------------------------------------------
// Wave detection (T3 - Level 4)
// ---------------------------------------------------------------------------
interface MilestoneInfo {
slug: string
status: string
features: { phase: string; status: string }[]
done: number
total: number
}
const WAVE_READY_PHASES = new Set(['planned', 'ready'])
function findReadyWave(root: string): string | null {
try {
const raw = execSync('sdlc milestone list --json', {
encoding: 'utf8',
cwd: root,
timeout: 15_000,
})
const milestones = JSON.parse(raw) as MilestoneInfo[]
const ready = milestones
.filter(m => m.status !== 'released' && m.total > 0)
.filter(m =>
m.features.every(f =>
WAVE_READY_PHASES.has(f.phase) || f.phase === 'released'
)
)
.filter(m =>
m.features.some(f => WAVE_READY_PHASES.has(f.phase))
)
.sort((a, b) => a.slug.localeCompare(b.slug))
return ready[0]?.slug ?? null
} catch (e) {
log.warn(`sdlc milestone list failed: ${e}`)
return null
}
}
// ---------------------------------------------------------------------------
// Async spawn (T4)
// ---------------------------------------------------------------------------
function spawnClaude(command: string, root: string): number {
const child = spawn('claude', ['--print', command], {
detached: true,
stdio: 'ignore',
cwd: root,
env: { ...process.env, SDLC_ROOT: root },
})
child.unref()
return child.pid ?? 0
}
// ---------------------------------------------------------------------------
// Main run function (T1, T3, T5)
// ---------------------------------------------------------------------------
export async function run(
_input: Record<string, never>,
root: string,
): Promise<ToolResult<DevDriverOutput>> {
const start = Date.now()
// ── Level 1: Flight lock ──────────────────────────────────────────────────
const lock = readLock(root)
if (lock && isLockActive(lock)) {
const mins = lockAgeMins(lock)
log.info(`flight lock active (${mins}m old) — waiting`)
return { ok: true, data: { action: 'waiting', lock_age_mins: mins }, duration_ms: Date.now() - start }
}
if (lock) {
log.info(`stale lock found (${lockAgeMins(lock)}m old) — proceeding`)
}
// ── Level 2: Quality check ────────────────────────────────────────────────
log.info('running quality check')
const qc = runQualityCheck(root)
if (qc.failed > 0) {
log.info(`quality failing: ${qc.failedNames.join(', ')}`)
return { ok: true, data: { action: 'quality_failing', failed_checks: qc.failedNames }, duration_ms: Date.now() - start }
}
log.info('quality checks passed')
// ── Level 3: Features with active directives ──────────────────────────────
if (hasActiveRuns(root)) {
log.info('active sdlc agent run detected — waiting')
return { ok: true, data: { action: 'waiting', reason: 'agent run in progress' }, duration_ms: Date.now() - start }
}
const feature = findActionableFeature(root)
if (feature) {
log.info(`advancing feature: ${feature.feature} (${feature.current_phase})`)
// Write lock before spawning
writeLock(root, {
started_at: new Date().toISOString(),
action: 'feature_advanced',
slug: feature.feature,
pid: 0, // will be overwritten after spawn
})
// Intentionally /sdlc-next — one step per tick, not /sdlc-run to completion
const pid = spawnClaude(`/sdlc-next ${feature.feature}`, root)
// Update lock with actual PID
writeLock(root, {
started_at: new Date().toISOString(),
action: 'feature_advanced',
slug: feature.feature,
pid,
})
log.info(`dispatched /sdlc-next ${feature.feature} (pid: ${pid})`)
return {
ok: true,
data: {
action: 'feature_advanced',
slug: feature.feature,
phase: feature.current_phase,
directive: feature.next_command || `/sdlc-next ${feature.feature}`,
},
duration_ms: Date.now() - start,
}
}
// ── Level 4: Wave ready ───────────────────────────────────────────────────
const milestone = findReadyWave(root)
if (milestone) {
log.info(`wave ready for milestone: ${milestone}`)
writeLock(root, {
started_at: new Date().toISOString(),
action: 'wave_started',
milestone,
pid: 0,
})
const pid = spawnClaude(`/sdlc-run-wave ${milestone}`, root)
writeLock(root, {
started_at: new Date().toISOString(),
action: 'wave_started',
milestone,
pid,
})
log.info(`dispatched /sdlc-run-wave ${milestone} (pid: ${pid})`)
return {
ok: true,
data: { action: 'wave_started', milestone },
duration_ms: Date.now() - start,
}
}
// ── Level 5: Idle ─────────────────────────────────────────────────────────
log.info('no actionable work found — idle')
return {
ok: true,
data: { action: 'idle', reason: 'no actionable work found' },
duration_ms: Date.now() - start,
}
}
// ---------------------------------------------------------------------------
// CLI entrypoint
// ---------------------------------------------------------------------------
const mode = getArgs()[0] ?? '--run'
const root = process.env.SDLC_ROOT ?? process.cwd()
if (mode === '--meta') {
console.log(JSON.stringify(meta))
exit(0)
} else if (mode === '--run') {
readStdin()
.then(raw => run(JSON.parse(raw || '{}') as Record<string, never>, root))
.then(result => { console.log(JSON.stringify(result)); exit(result.ok ? 0 : 1) })
.catch(e => { console.log(JSON.stringify({ ok: false, error: String(e) })); exit(1) })
} else {
console.error(`Unknown mode: ${mode}. Use --meta or --run.`)
exit(1)
}

View File

@ -1,34 +0,0 @@
# Quality Check
Runs checks defined in `.sdlc/tools/quality-check/config.yaml` and reports pass/fail.
## Usage
```bash
# Run all configured checks
sdlc tool run quality-check
# Filter to checks whose name matches a string
sdlc tool run quality-check --scope test
```
## How it works
Reads `checks` from `.sdlc/tools/quality-check/config.yaml`, runs each script as a shell
command in the project root, and reports pass/fail with the last 500 characters of output.
## Adding checks
Edit `.sdlc/tools/quality-check/config.yaml`:
```yaml
checks:
- name: test
description: Run unit tests
script: cargo test --all
- name: lint
description: Run linter
script: cargo clippy --all -- -D warnings
```
The quality-check tool picks them up automatically — no code changes needed.

View File

@ -1,12 +0,0 @@
# quality-check tool configuration
# Add your project's quality checks below.
# Each check runs its `script` as a shell command in the project root.
#
# Example:
# checks:
# - name: test
# description: Run unit tests
# script: cargo test --all
name: quality-check
version: "0.3.0"
checks:

View File

@ -1,294 +0,0 @@
/**
* Quality Check
* =============
* Runs checks defined in .sdlc/tools/quality-check/config.yaml and reports pass/fail.
*
* WHAT IT DOES
* ------------
* --run: Reads JSON from stdin: { "scope"?: "string" }
* Loads checks from .sdlc/tools/quality-check/config.yaml.
* Runs each check's script as a shell command, records pass/fail + output.
* If scope is provided, only runs checks whose name matches the filter string.
* Returns ToolResult<{ passed, failed, checks[] }>.
*
* --meta: Writes ToolMeta JSON to stdout. Used by `sdlc tool sync`.
*
* WHAT IT READS
* -------------
* - .sdlc/tools/quality-check/config.yaml
* checks[]: { name, description, script }
*
* WHAT IT WRITES
* --------------
* - STDERR: structured log lines via _shared/log.ts
* - STDOUT: JSON only (ToolResult shape from _shared/types.ts)
*
* EXTENDING
* ---------
* Add or edit checks in .sdlc/tools/quality-check/config.yaml:
* checks:
* - name: test
* description: Run unit tests
* script: cargo test --all
* The quality-check tool picks them up automatically no code changes needed.
*/
import type { ToolMeta, ToolResult } from '../_shared/types.ts'
import { makeLogger } from '../_shared/log.ts'
import { getArgs, readStdin, exit } from '../_shared/runtime.ts'
import { execSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
const log = makeLogger('quality-check')
// ---------------------------------------------------------------------------
// Tool metadata
// ---------------------------------------------------------------------------
export const meta: ToolMeta = {
name: 'quality-check',
display_name: 'Quality Check',
description: 'Runs checks from .sdlc/tools/quality-check/config.yaml and reports pass/fail',
version: '0.3.0',
requires_setup: false,
input_schema: {
type: 'object',
properties: {
scope: {
type: 'string',
description: 'Optional filter — only run checks whose name matches this string',
},
},
},
output_schema: {
type: 'object',
properties: {
passed: { type: 'number' },
failed: { type: 'number' },
checks: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
description: { type: 'string' },
command: { type: 'string' },
status: { type: 'string', enum: ['passed', 'failed'] },
output: { type: 'string' },
duration_ms: { type: 'number' },
},
},
},
},
},
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface PlatformCommand {
name: string
description?: string
script: string
}
interface CheckResult {
name: string
description: string
command: string
status: 'passed' | 'failed'
output: string
duration_ms: number
}
interface QualityCheckOutput {
passed: number
failed: number
checks: CheckResult[]
}
// ---------------------------------------------------------------------------
// Config YAML parser — reads checks[] from tool-local config.yaml
// ---------------------------------------------------------------------------
/**
* Parse the `checks:` array from the tool's config.yaml.
* Handles the specific YAML shape used by quality-check:
* checks:
* - name: <string>
* description: <string>
* script: <single-quoted or bare string>
*/
function parseChecksFromYaml(content: string): PlatformCommand[] {
const checks: PlatformCommand[] = []
const lines = content.split('\n')
let inChecks = false
let current: Partial<PlatformCommand> | null = null
for (const line of lines) {
// Top-level `checks:` section header
if (/^checks:/.test(line)) {
inChecks = true
continue
}
// Any other top-level key ends the checks section
if (/^\S/.test(line) && !/^checks:/.test(line)) {
inChecks = false
}
if (!inChecks) continue
// New item: ` - name: <value>`
const itemMatch = line.match(/^\s{2}-\s+name:\s*(.*)$/)
if (itemMatch) {
if (current?.name && current?.script) {
checks.push(current as PlatformCommand)
}
current = { name: unquoteYaml(itemMatch[1].trim()), description: '', script: '' }
continue
}
if (!current) continue
const descMatch = line.match(/^\s+description:\s*(.*)$/)
if (descMatch) {
current.description = unquoteYaml(descMatch[1].trim())
continue
}
const scriptMatch = line.match(/^\s+script:\s*(.*)$/)
if (scriptMatch) {
current.script = unquoteYaml(scriptMatch[1].trim())
continue
}
}
if (current?.name && current?.script) {
checks.push(current as PlatformCommand)
}
return checks
}
/** Strip surrounding single or double quotes from a YAML scalar value. */
function unquoteYaml(s: string): string {
return s.replace(/^'([\s\S]*)'$/, '$1').replace(/^"([\s\S]*)"$/, '$1')
}
/** Load checks from the tool's own config.yaml. Returns [] on any error. */
function loadChecks(root: string): PlatformCommand[] {
const configPath = join(root, '.sdlc', 'tools', 'quality-check', 'config.yaml')
try {
const raw = readFileSync(configPath, 'utf8')
return parseChecksFromYaml(raw)
} catch (e) {
log.warn(`Could not read tool config at ${configPath}: ${e}`)
return []
}
}
// ---------------------------------------------------------------------------
// Run — execute platform checks
// ---------------------------------------------------------------------------
export async function run(
input: { scope?: string },
root: string,
): Promise<ToolResult<QualityCheckOutput>> {
const start = Date.now()
const commands = loadChecks(root)
if (commands.length === 0) {
log.warn('No checks configured in .sdlc/tools/quality-check/config.yaml — nothing to run')
const duration_ms = Date.now() - start
return {
ok: true,
data: { passed: 0, failed: 0, checks: [] },
duration_ms,
}
}
// Apply scope filter
const scope = input.scope?.trim()
const filtered = scope
? commands.filter(c => c.name.includes(scope))
: commands
log.info(`running ${filtered.length} check(s)${scope ? ` (scope: "${scope}")` : ''}`)
const checks: CheckResult[] = []
for (const cmd of filtered) {
const checkStart = Date.now()
log.info(`running check: ${cmd.name}`)
let status: 'passed' | 'failed' = 'passed'
let output = ''
try {
const result = execSync(cmd.script, {
cwd: root,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
})
output = result.slice(-500) // last 500 chars
} catch (e: unknown) {
status = 'failed'
if (e && typeof e === 'object' && 'stdout' in e && 'stderr' in e) {
const err = e as { stdout?: string; stderr?: string }
const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`
output = combined.slice(-500)
} else {
output = String(e).slice(-500)
}
}
const duration_ms = Date.now() - checkStart
log.info(` ${cmd.name}: ${status} (${duration_ms}ms)`)
checks.push({
name: cmd.name,
description: cmd.description ?? '',
command: cmd.script,
status,
output,
duration_ms,
})
}
const passed = checks.filter(c => c.status === 'passed').length
const failed = checks.filter(c => c.status === 'failed').length
const duration_ms = Date.now() - start
log.info(`done: ${passed} passed, ${failed} failed in ${duration_ms}ms`)
return {
ok: failed === 0,
data: { passed, failed, checks },
duration_ms,
}
}
// ---------------------------------------------------------------------------
// CLI entrypoint
// ---------------------------------------------------------------------------
const mode = getArgs()[0] ?? '--run'
const root = process.env.SDLC_ROOT ?? process.cwd()
if (mode === '--meta') {
console.log(JSON.stringify(meta))
exit(0)
} else if (mode === '--run') {
readStdin()
.then(raw => run(JSON.parse(raw || '{}') as { scope?: string }, root))
.then(result => { console.log(JSON.stringify(result)); exit(result.ok ? 0 : 1) })
.catch(e => { console.log(JSON.stringify({ ok: false, error: String(e) })); exit(1) })
} else {
console.error(`Unknown mode: ${mode}. Use --meta or --run.`)
exit(1)
}

View File

@ -1,113 +0,0 @@
# telegram-recap
Fetches Telegram chat messages from the configured time window and emails a digest via SMTP.
Delegates all logic to `sdlc telegram digest` — no duplicate implementation.
## Prerequisites
1. A Telegram bot created via [@BotFather](https://t.me/botfather)
2. The bot added to the chats you want to digest
3. `sdlc telegram poll` running (or having run) to populate the local message database
4. SMTP credentials (e.g. [Resend](https://resend.com), SendGrid, Gmail SMTP)
## Setup
### 1. Configure secrets
The tool requires these environment variables. Set them in your orchestrator secrets or shell:
| Variable | Description |
|---|---|
| `TELEGRAM_BOT_TOKEN` | Bot API token from @BotFather |
| `SMTP_HOST` | SMTP server hostname (e.g. `smtp.resend.com`) |
| `SMTP_PORT` | SMTP port (`587` for STARTTLS, `465` for SSL) |
| `SMTP_USERNAME` | SMTP auth username |
| `SMTP_PASSWORD` | SMTP auth password or API key |
| `SMTP_FROM` | From address (e.g. `digest@yourdomain.com`) |
| `SMTP_TO` | Recipient(s), comma-separated |
### 2. Run setup to verify
```bash
sdlc tool run telegram-recap --setup
```
This calls `sdlc telegram status` to verify the bot token and database connectivity.
Returns `{ ok: true, data: { status_output: "..." } }` on success.
## Usage
### Dry run (preview without sending)
```bash
sdlc tool run telegram-recap --input '{"dry_run": true}'
```
### Send digest
```bash
sdlc tool run telegram-recap --input '{}'
```
### Custom window
```bash
# Last 48 hours instead of the default 24
sdlc tool run telegram-recap --input '{"window_hours": 48}'
```
### Specific chat IDs
```bash
sdlc tool run telegram-recap --input '{"chat_ids": ["-100123456789"]}'
```
## Output
```json
{
"ok": true,
"data": {
"dry_run": false,
"total_messages": 42,
"chat_count": 3,
"period_start": "2026-03-01T08:00:00Z",
"period_end": "2026-03-02T08:00:00Z",
"sent_to": ["you@example.com"]
},
"duration_ms": 1234
}
```
## Scheduling with the orchestrator
Use the sdlc orchestrator for recurrence instead of systemd:
```bash
# Daily digest at the current time
sdlc orchestrate add telegram-recap \
--tool telegram-recap \
--input '{}' \
--at "now" \
--every 86400
```
This removes the need for systemd timers. The orchestrator runs the tool on schedule
and streams results to the sdlc UI via SSE.
## Troubleshooting
**"Bot token check failed"**
`TELEGRAM_BOT_TOKEN` is missing or invalid. Verify with `sdlc telegram status`.
**"sdlc telegram digest failed"**
→ Check that `sdlc telegram poll` has been running and the database exists at
`.sdlc/telegram/messages.db`. Run `sdlc telegram status` for diagnostics.
**SMTP errors**
→ Check `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`. Common mistake:
using port 465 with STARTTLS or port 587 with SSL — match port to protocol.
**No messages in digest**
→ The configured chat IDs may not match what the bot has access to, or the time
window contains no messages. Try `--input '{"window_hours": 168}'` (7 days) to widen the window.

View File

@ -1,5 +0,0 @@
name: telegram-recap
version: "1.0.0"
description: "Fetch and email a Telegram chat digest via SMTP"
# Add tool-specific config here (optional — env vars take precedence)
# See README.md for full configuration reference.

View File

@ -1,303 +0,0 @@
/**
* telegram-recap
* ==============
* Fetches Telegram chat messages from the configured window and emails a digest.
* Delegates all logic to `sdlc telegram digest --json`.
*
* WHAT IT DOES
* ------------
* --setup: Runs `sdlc telegram status` to verify bot token and DB connectivity.
* Returns success/failure with bot identity.
*
* --run: Reads JSON from stdin: { window_hours?, dry_run?, chat_ids? }
* Builds args and spawns `sdlc telegram digest --json [args]`.
* Maps the digest JSON summary ToolResult.
*
* --meta: Writes ToolMeta JSON to stdout. Declares required secrets.
*
* SECRETS (injected as env vars by the orchestrator)
* ---------------------------------------------------
* TELEGRAM_BOT_TOKEN Required Telegram bot API token (from @BotFather)
* SMTP_HOST Required SMTP server hostname (e.g. smtp.resend.com)
* SMTP_PORT Required SMTP port (e.g. 587 for STARTTLS, 465 for SSL)
* SMTP_USERNAME Required SMTP authentication username
* SMTP_PASSWORD Required SMTP authentication password or API key
* SMTP_FROM Required From address for digest emails
* SMTP_TO Required Recipient address(es), comma-separated
*
* WHAT IT READS
* -------------
* - $SDLC_ROOT/.sdlc/telegram/messages.db (populated by `sdlc telegram poll`)
*
* WHAT IT WRITES
* --------------
* - STDERR: structured log lines via _shared/log.ts
* - STDOUT: JSON only (ToolResult shape)
*/
import type { ToolMeta as BaseToolMeta, ToolResult } from '../_shared/types.ts'
import { makeLogger } from '../_shared/log.ts'
import { getArgs, readStdin, exit } from '../_shared/runtime.ts'
import { spawnSync } from 'node:child_process'
const log = makeLogger('telegram-recap')
// ---------------------------------------------------------------------------
// Extended ToolMeta type with secrets, tags, result_actions
// (the Rust runtime supports these fields; they're not yet in the shared type)
// ---------------------------------------------------------------------------
interface ToolMeta extends BaseToolMeta {
secrets?: Array<{ env_var: string; description: string; required?: boolean }>
tags?: string[]
result_actions?: Array<{
label: string
icon?: string
condition?: string
prompt_template: string
confirm?: string
}>
}
// ---------------------------------------------------------------------------
// Tool metadata
// ---------------------------------------------------------------------------
export const meta: ToolMeta = {
name: 'telegram-recap',
display_name: 'Telegram Recap',
description:
'Fetch and email a Telegram chat digest — pulls messages from the configured window and sends via SMTP',
version: '1.0.0',
requires_setup: true,
setup_description:
'Verifies TELEGRAM_BOT_TOKEN by calling Telegram getMe and checking database connectivity',
input_schema: {
type: 'object',
required: [],
properties: {
window_hours: {
type: 'number',
description: 'Time window in hours to include in the digest (default: 24)',
},
dry_run: {
type: 'boolean',
description: 'Print digest to stdout without sending email',
},
chat_ids: {
type: 'array',
items: { type: 'string' },
description: 'Override configured chat IDs (e.g. ["-100123456789"])',
},
},
},
output_schema: {
type: 'object',
properties: {
dry_run: { type: 'boolean', description: 'Whether this was a dry run' },
total_messages: { type: 'number', description: 'Messages included in digest' },
chat_count: { type: 'number', description: 'Number of chats included' },
period_start: { type: 'string', description: 'ISO 8601 period start timestamp' },
period_end: { type: 'string', description: 'ISO 8601 period end timestamp' },
sent_to: {
type: 'array',
items: { type: 'string' },
description: 'Recipient addresses (empty on dry run)',
},
},
},
secrets: [
{
env_var: 'TELEGRAM_BOT_TOKEN',
description: 'Telegram bot API token (from @BotFather)',
required: true,
},
{
env_var: 'SMTP_HOST',
description: 'SMTP server hostname (e.g. smtp.resend.com)',
required: true,
},
{
env_var: 'SMTP_PORT',
description: 'SMTP server port (e.g. 587 for STARTTLS, 465 for SSL)',
required: true,
},
{
env_var: 'SMTP_USERNAME',
description: 'SMTP authentication username',
required: true,
},
{
env_var: 'SMTP_PASSWORD',
description: 'SMTP authentication password or API key',
required: true,
},
{
env_var: 'SMTP_FROM',
description: 'From address for digest emails (e.g. digest@yourdomain.com)',
required: true,
},
{
env_var: 'SMTP_TO',
description: 'Recipient address(es), comma-separated',
required: true,
},
],
tags: ['telegram', 'email', 'digest'],
result_actions: [
{
label: 'Send test digest',
icon: 'send',
condition: '$.ok == true',
prompt_template:
'Run the telegram-recap tool with dry_run: true to preview the digest without sending email.',
confirm: 'This will fetch messages and display the digest without sending email.',
},
],
}
// ---------------------------------------------------------------------------
// Input / output types
// ---------------------------------------------------------------------------
interface Input {
window_hours?: number
dry_run?: boolean
chat_ids?: string[]
}
interface DigestOutput {
dry_run: boolean
total_messages: number
chat_count: number
period_start: string
period_end: string
sent_to: string[]
}
// ---------------------------------------------------------------------------
// setup() — verify bot token and database connectivity
// ---------------------------------------------------------------------------
export function setup(): ToolResult<{ status_output: string }> {
const start = Date.now()
log.info('running: sdlc telegram status')
const proc = spawnSync('sdlc', ['telegram', 'status'], {
env: process.env,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
})
const stdout = (proc.stdout ?? '').trim()
const stderr = (proc.stderr ?? '').trim()
const exitCode = proc.status ?? 1
if (exitCode !== 0) {
log.error(`sdlc telegram status failed (exit ${exitCode}): ${stderr}`)
return {
ok: false,
error: `Bot token check failed (exit ${exitCode}): ${stderr || stdout || 'no output'}`,
duration_ms: Date.now() - start,
}
}
log.info(`setup ok:\n${stdout}`)
return {
ok: true,
data: { status_output: stdout },
duration_ms: Date.now() - start,
}
}
// ---------------------------------------------------------------------------
// run() — fetch messages and send (or preview) the digest
// ---------------------------------------------------------------------------
export function run(input: Input): ToolResult<DigestOutput> {
const start = Date.now()
const args: string[] = ['telegram', 'digest', '--json']
if (input.dry_run) {
args.push('--dry-run')
}
if (input.window_hours !== undefined) {
args.push('--window', String(Math.round(input.window_hours)))
}
if (input.chat_ids && input.chat_ids.length > 0) {
for (const id of input.chat_ids) {
args.push('--chat', id)
}
}
log.info(`running: sdlc ${args.join(' ')}`)
const proc = spawnSync('sdlc', args, {
env: process.env,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
})
const stdout = (proc.stdout ?? '').trim()
const stderr = (proc.stderr ?? '').trim()
const exitCode = proc.status ?? 1
if (exitCode !== 0) {
log.error(`sdlc telegram digest failed (exit ${exitCode})`)
return {
ok: false,
error: `sdlc telegram digest failed (exit ${exitCode}): ${stderr || stdout || 'no output'}`,
duration_ms: Date.now() - start,
}
}
let parsed: DigestOutput
try {
parsed = JSON.parse(stdout) as DigestOutput
} catch (e) {
return {
ok: false,
error: `Failed to parse digest JSON output: ${e}. Raw stdout: ${stdout}`,
duration_ms: Date.now() - start,
}
}
log.info(
`digest ok: ${parsed.total_messages} messages across ${parsed.chat_count} chats, sent_to=${JSON.stringify(parsed.sent_to)}`,
)
return {
ok: true,
data: parsed,
duration_ms: Date.now() - start,
}
}
// ---------------------------------------------------------------------------
// CLI entrypoint
// ---------------------------------------------------------------------------
const mode = getArgs()[0] ?? '--run'
if (mode === '--meta') {
console.log(JSON.stringify(meta))
exit(0)
} else if (mode === '--setup') {
const result = setup()
console.log(JSON.stringify(result))
exit(result.ok ? 0 : 1)
} else if (mode === '--run') {
readStdin()
.then(raw => {
const result = run(JSON.parse(raw || '{}') as Input)
console.log(JSON.stringify(result))
exit(result.ok ? 0 : 1)
})
.catch(e => {
console.log(JSON.stringify({ ok: false, error: String(e) }))
exit(1)
})
} else {
console.error(`Unknown mode: ${mode}. Use --meta, --setup, or --run.`)
exit(1)
}

View File

@ -1,52 +0,0 @@
# SDLC Tools
Project-specific tools installed by sdlc. Use `sdlc tool run <name>` to invoke.
Run `sdlc tool sync` to regenerate this file from live tool metadata.
---
## ama — AMA — Ask Me Anything
Answers questions about the codebase by searching a pre-built keyword index.
**Run:** `sdlc tool run ama --question "..."`
**Setup required:** Yes — `sdlc tool run ama --setup`
_Indexes source files for keyword search (run once, then re-run when files change significantly)_
---
## quality-check — Quality Check
Runs checks from .sdlc/tools/quality-check/config.yaml and reports pass/fail.
**Run:** `sdlc tool run quality-check`
**Setup required:** No
_Edit `.sdlc/tools/quality-check/config.yaml` to add your project's checks_
---
## dev-driver — Dev Driver
Finds the next development action and dispatches it — advances the project one step per tick.
**Run:** `sdlc tool run dev-driver`
**Setup required:** No
_Configure via orchestrator: Label=dev-driver, Tool=dev-driver, Input={}, Recurrence=14400. See `.sdlc/tools/dev-driver/README.md` for full docs._
---
## telegram-recap — Telegram Recap
Fetch and email a Telegram chat digest — pulls messages from the configured window and sends via SMTP.
**Run:** `sdlc tool run telegram-recap --input '{}'`
**Setup required:** Yes — `sdlc tool run telegram-recap --setup`
_Requires 7 secrets: TELEGRAM_BOT_TOKEN, SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, SMTP_FROM, SMTP_TO. Schedule with orchestrator (--every 86400) for a daily digest._
---
## Adding a Custom Tool
Run `sdlc tool scaffold <name> "<description>"` to create a new tool skeleton.
Then implement the `run()` function in `.sdlc/tools/<name>/tool.ts` and run `sdlc tool sync`.

View File

@ -1,99 +0,0 @@
# AGENTS.md
Agent instructions for tidalDB.
## Team
| Agent | Identity | Model | Invoke when |
|-------|----------|-------|-------------|
| `@tidal-engineer` | Jon Gjengset — principal Rust database engineer | opus | Implementing features, storage internals, signal system, query engine, debugging correctness |
| `@tidal-visionary` | Spencer Kimball — product and roadmap strategist | opus | Planning milestones, scoping phases, build-vs-defer decisions, roadmap sequencing |
| `@tidal-researcher` | Andy Pavlo — database systems researcher | opus | Prior art surveys, library evaluation, architectural research, producing `docs/research/` docs |
| `@tidal-distributed` | Kyle Kingsbury — distributed-systems engineer | opus | Network transports, cluster coordination, multi-node deployment, cross-node query routing, HA |
| `@tidal-storyteller` | Marketing and technical writer | sonnet | Marketing site (`site/`), blog posts, public-facing copy |
Agent definitions live in `.claude/agents/`. **`CLAUDE.md §Agents` is the canonical roster** (this table mirrors it — keep them in sync); see it for the `@knowledge-librarian` utility agent and the vendored `@kai-park`/`@kaya-osei`/`@mira-vasquez` Aeries team that backs the `aeries-*` skills.
---
<!-- sdlc:start -->
## SDLC
> **Required reading:** `.sdlc/guidance.md` — engineering principles that govern all implementation decisions on this project. <!-- sdlc:guidance -->
This project uses `sdlc` as its SDLC state machine. `sdlc` manages feature lifecycle, artifacts, tasks, and milestones. It emits structured directives via `sdlc next --json` that any consumer (Claude Code, custom scripts, or humans) acts on to decide what to do next.
Consumer scaffolding is installed globally under `~/.claude/commands/`, `~/.gemini/commands/`, `~/.opencode/command/`, and `~/.agents/skills/` — available across all projects. Use `/sdlc-specialize` in Claude Code to generate a project-specific AI team (agents + skills) tailored to this project's tech stack and roles.
### Key Commands
- `sdlc feature create <slug> --title "..."` — create a new feature
- `sdlc next --for <slug> --json` — get the next action directive (JSON)
- `sdlc next` — show all active features and their next actions
- `sdlc artifact approve <slug> <type>` — approve an artifact to advance the phase
- `sdlc state` — show project state
- `sdlc feature list` — list all features and their phases
- `sdlc task list [<slug>]` — list tasks for a feature (or all tasks)
### Lifecycle
draft → specified → planned → ready → implementation → review → audit → qa → merge → released
Treat this lifecycle as the default pathway. You can use explicit manual transitions when needed, but approvals/artifacts are the recommended way to keep quality and traceability.
### Artifact Types
`spec` `design` `tasks` `qa_plan` `review` `audit` `qa_results`
### CRITICAL: Never edit .sdlc/ YAML directly
All state changes go through `sdlc` CLI commands. See §6 of `.sdlc/guidance.md` for the full command reference. Direct YAML edits corrupt state.
### Directive Interface
Use `sdlc next --for <slug> --json` to get the next directive. The JSON output tells the consumer what to do next (action, message, output_path, is_heavy, gates).
### Consumer Commands
- `/sdlc-next <slug>` — execute one step, then stop (human controls cadence)
- `/sdlc-run <slug>` — run autonomously to completion
- `/sdlc-status [<slug>]` — show current state
- `/sdlc-plan` — distribute a plan into milestones, features, and tasks
- `/sdlc-milestone-uat <milestone-slug>` — run the acceptance test for a milestone
- `/sdlc-pressure-test <milestone-slug>` — pressure-test a milestone against user perspectives
- `/sdlc-vision-adjustment [description]` — align all docs, sdlc state, and code to a vision change
- `/sdlc-architecture-adjustment [description]` — align all docs, code, and sdlc state to an architecture change
- `/sdlc-enterprise-readiness [--stage <stage>]` — analyze production readiness
- `/sdlc-setup-quality-gates` — set up pre-commit hooks and quality gates
- `/sdlc-cookbook <milestone-slug>` — create developer-scenario cookbook recipes
- `/sdlc-cookbook-run <milestone-slug>` — execute cookbook recipes and record results
- `/sdlc-ponder [slug]` — open the ideation workspace for exploring and committing ideas
- `/sdlc-ponder-commit <slug>` — crystallize a pondered idea into milestones and features
- `/sdlc-guideline <slug-or-problem>` — build an evidence-backed guideline through five research perspectives and TOC-first distillation
- `/sdlc-suggest` — analyze project state and suggest 3-5 ponder topics to explore next
- `/sdlc-beat [domain | feature:<slug> | --week]` — step back with a senior leadership lens; evaluate if we're building the right thing in the right direction; stores history in `.sdlc/beat.yaml`
- `/sdlc-recruit <role>` — recruit an expert thought partner as a persistent agent
- `/sdlc-empathy <subject>` — deep user perspective interviews before decisions
- `/sdlc-spike <slug> — <need>; [see <ref>]` — research, prototype, validate, and report; produces working prototype + findings in `.sdlc/spikes/<slug>/findings.md`
- `/sdlc-convo-mine [file or text]` — mine conversation dumps for signal; apply 5 perspective lenses, group themes, launch parallel ponder sessions per group
### Tool Suite
<!-- sdlc:tools -->
Project-scoped TypeScript tools in `.sdlc/tools/` — callable by agents and humans
during any lifecycle phase. Read `.sdlc/tools/tools.md` for the full help menu.
- `sdlc tool list` — show installed tools
- `sdlc tool run <name> [args]` — run a tool; pass `--json '{...}'` for complex input
- `sdlc tool sync` — regenerate `tools.md` after adding a custom tool
- `sdlc tool scaffold <name> "desc"` — create a new tool skeleton
**Core tools:** `ama` (codebase Q&A), `quality-check` (runs platform shell gates)
Use `/sdlc-tool-run`, `/sdlc-tool-build`, `/sdlc-tool-audit`, `/sdlc-tool-uat` in Claude Code for guided tool workflows.
<!-- /sdlc:tools -->
Project: tidalDB
<!-- sdlc:end -->

308
API.md
View File

@ -4,10 +4,7 @@
How developers interact with tidalDB. This document covers initialization, schema definition, write operations, queries, and the feedback loop.
tidalDB has two interfaces:
1. **Rust library** — embed it in your process. No network overhead, no serialization. The API is Rust types and method calls.
2. **HTTP server** (`tidal-server`) — a standalone Axum-based server exposing a REST API for write, query, and health operations. Useful for polyglot stacks or when embedding isn't practical.
tidalDB is an embeddable Rust library. You link it into your process. There is no separate server, no network protocol, no client SDK. The API is Rust types and method calls.
---
@ -24,21 +21,15 @@ tidalDB has two interfaces:
- [Writing Relationships](#writing-relationships)
- [Writing Signals](#writing-signals)
- [Query Language](#query-language)
- [RETRIEVE Feeds, Browse, Related](#retrieve--feeds-browse-related)
- [SEARCH Text + Semantic Retrieval](#search--text--semantic-retrieval)
- [SUGGEST Autocomplete and Suggestions](#suggest--autocomplete-and-suggestions)
- [RETRIEVE -- Feeds, Browse, Related](#retrieve--feeds-browse-related)
- [SEARCH -- Text + Semantic Retrieval](#search--text--semantic-retrieval)
- [SUGGEST -- Autocomplete and Suggestions](#suggest--autocomplete-and-suggestions)
- [Filters](#filters)
- [Sort Modes](#sort-modes)
- [Diversity Constraints](#diversity-constraints)
- [Pagination](#pagination)
- [Response Format](#response-format)
- [Lifecycle and Operations](#lifecycle-and-operations)
- [HTTP Server API](#http-server-api)
- [Authentication](#authentication)
- [Write Endpoints](#write-endpoints)
- [Query Endpoints](#query-endpoints)
- [Health Endpoints](#health-endpoints)
- [Error Responses](#error-responses)
---
@ -56,7 +47,7 @@ let mut schema = SchemaBuilder::new();
let _ = schema.signal("view", EntityKind::Item, DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
})
.windows(&[Window::OneHour, Window::TwentyFourHours, Window::ThirtyDays, Window::AllTime])
.windows(&[Window::OneHour, Window::TwentyFourHours, Window::AllTime])
.velocity(true)
.add();
let schema = schema.build().expect("valid schema");
@ -100,7 +91,7 @@ let mut schema = SchemaBuilder::new();
let _ = schema.signal("view", EntityKind::Item, DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
})
.windows(&[Window::OneHour, Window::TwentyFourHours, Window::SevenDays, Window::ThirtyDays, Window::AllTime])
.windows(&[Window::OneHour, Window::TwentyFourHours, Window::SevenDays, Window::AllTime])
.velocity(true)
.add();
@ -241,28 +232,17 @@ Relationships are directional edges between entities (follows, blocks). Used for
```rust
use tidaldb::schema::EntityId;
use tidaldb::schema::Timestamp;
use tidaldb::entities::RelationshipType;
// User follows a creator.
db.write_relationship(
EntityId::new(123), // from (user)
RelationshipType::Follows, // relationship type
EntityId::new(100), // to (creator)
1.0, // weight
EntityId::new(123), // user
EntityId::new(100), // creator
"follows",
1.0, // weight
Timestamp::now(),
)?;
```
**Relationship types:**
| Variant | Meaning |
|---|---|
| `Follows` | User follows a creator |
| `Blocks` | User blocks a creator |
| `InteractionWeight` | Weighted interaction edge |
| `Hide` | User hides a creator |
| `Mute` | User mutes a creator |
### Writing Signals
Signals are how the feedback loop closes. A single signal write atomically updates:
@ -313,7 +293,7 @@ Three operations: **RETRIEVE** (feed generation, browse, related), **SEARCH** (t
All queries return ranked results with scores. The application renders -- it never re-ranks.
### RETRIEVE Feeds, Browse, Related
### RETRIEVE -- Feeds, Browse, Related
RETRIEVE generates ranked content lists. It handles personalized feeds, category browse, trending, following, related content, and every other surface described in [USE_CASES.md](USE_CASES.md).
@ -435,7 +415,7 @@ let query = Retrieve::builder()
let results = db.retrieve(&query)?;
```
### SEARCH Text + Semantic Retrieval
### SEARCH -- Text + Semantic Retrieval
Search combines full-text BM25 relevance with semantic similarity via RRF (Reciprocal Rank Fusion). Text relevance is the floor -- an irrelevant result never surfaces just because the user likes the creator.
@ -475,7 +455,7 @@ let query = Search::builder()
let results = db.search(&query)?;
```
### Query Composition SEARCH within Scoped Results
### Query Composition -- SEARCH within Scoped Results
SEARCH can be composed with scope constraints. This enables searching within trending, within a cohort, or within any candidate set.
@ -525,28 +505,21 @@ let results = db.search(&query)?;
| `Category { name }` | Items in a category |
| `Collection { id }` | Items in a collection |
### SUGGEST Autocomplete and Suggestions
### SUGGEST -- Autocomplete and Suggestions
```rust
use tidaldb::query::suggest::Suggest;
use tidaldb::schema::EntityId;
// Autocomplete on partial query.
let req = Suggest { prefix: "jazz pia".into(), for_user: None, limit: 5 };
let suggestions = db.suggest(&req)?;
// Returns Vec<Suggestion> with text and frequency.
// Personalized autocomplete.
let req = Suggest { prefix: "jazz pia".into(), for_user: Some(EntityId::new(123)), limit: 5 };
let suggestions = db.suggest(&req)?;
// Trending searches (empty prefix).
let req = Suggest { prefix: "".into(), for_user: None, limit: 10 };
let trending = db.suggest(&req)?;
```
Note: `for_user` is `Option<EntityId>`, not `Option<u64>`.
---
## Filters
@ -560,16 +533,9 @@ use tidaldb::storage::indexes::filter::FilterExpr;
FilterExpr::eq("category", "jazz") // exact match on category
FilterExpr::eq("format", "video") // exact match on format
FilterExpr::Tag("tutorial".to_string()) // tag match
FilterExpr::CreatorEq(100) // exact match on creator ID
FilterExpr::DurationMin(60) // minimum duration (seconds)
FilterExpr::DurationMax(600) // maximum duration (seconds)
FilterExpr::CreatedAfter(ts_nanos) // created after timestamp (nanoseconds)
FilterExpr::CreatedBefore(ts_nanos) // created before timestamp (nanoseconds)
FilterExpr::eq("tags", "tutorial") // tag match
```
Note: `FilterExpr::eq()` only routes `"category"` and `"format"` to typed variants. For tags, use `FilterExpr::Tag(...)` directly.
### Engagement Threshold Filters
```rust
@ -627,8 +593,8 @@ Diversity is a post-scoring pass. After candidates are scored, diversity constra
use tidaldb::ranking::diversity::DiversityConstraints;
let diversity = DiversityConstraints::new()
.max_per_creator(2) // No more than 2 items per creator
.format_mix(0.4); // No format > 40% of results
.max_per_creator(2) // No more than 2 items per creator
.max_format_fraction(0.4); // No format > 40% of results
let query = Retrieve::builder()
.profile("for_you")
@ -700,12 +666,8 @@ pub struct Results {
pub constraints_satisfied: bool,
/// Warnings generated during query execution.
pub warnings: Vec<String>,
/// Session snapshot at query time (populated when `for_session` is set).
pub session_snapshot: Option<SessionSnapshot>,
/// The degradation level under which this query was executed.
pub degradation_level: DegradationLevel,
/// Per-query execution statistics (timing, candidate counts, profile name).
pub stats: QueryStats,
}
pub struct RetrieveResult {
@ -729,9 +691,7 @@ pub struct SearchResults {
pub total_candidates: usize,
pub constraints_satisfied: bool,
pub warnings: Vec<String>,
pub session_snapshot: Option<SessionSnapshot>,
pub degradation_level: DegradationLevel,
pub stats: QueryStats,
}
pub struct SearchResultItem {
@ -851,239 +811,5 @@ db.reload_text_index()?;
| **Handle negative signals** | Call `signal` with skip/hide | Preference decay, exclusion in future queries |
| **Scope trending by cohort** | Specify cohort name in retrieve query | Cohort-scoped signal aggregation, same ranking profile |
| **Search within scope** | Specify `within` on search query | Intersects text/vector retrieval with scoped candidate set |
| **HTTP write** | `POST /items`, `/embeddings`, `/signals` | Same as library write path, via JSON |
| **HTTP query** | `GET /feed`, `/search` | Same as library query, via query params |
One process. One query interface. One operational model.
---
## HTTP Server API
`tidal-server` is a standalone HTTP server wrapping the Rust library API. It exposes a REST interface for write, query, and health operations.
### Running the Server
```bash
# Ephemeral (in-memory) mode on default port 9400.
tidal-server standalone
# Persistent mode with custom schema.
tidal-server standalone --data-dir /var/lib/tidaldb --schema config/schema.yaml
# Custom port and metrics endpoint.
PORT=8080 tidal-server standalone --metrics 127.0.0.1:9091
```
| Flag / Env | Default | Description |
|---|---|---|
| `--data-dir <PATH>` | ephemeral | Persistent data directory |
| `--schema <PATH>` | bundled default | YAML schema file |
| `--metrics <ADDR>` | disabled | Bind Prometheus metrics server |
| `PORT` | `9400` | Listen port |
| `TIDAL_API_KEY` | unset (no auth) | Bearer token for protected routes |
### Authentication
When `TIDAL_API_KEY` is set, all write and query endpoints require a Bearer token:
```
Authorization: Bearer <your-api-key>
```
Health endpoints are always public. If the key is missing or invalid, the server returns:
```json
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
{"error": "missing or invalid api key"}
```
### Middleware
Protected routes have these limits applied:
| Layer | Behavior |
|---|---|
| Body limit | 2 MB max request body (413 if exceeded) |
| Timeout | 30 second wall-clock limit (408 if exceeded) |
| Concurrency | 100 max in-flight requests (queued beyond that) |
Health endpoints are exempt from timeout and concurrency limits so probes are never dropped under load.
### Write Endpoints
#### `POST /items`
Create an item with metadata.
```json
{
"entity_id": 1,
"metadata": {
"title": "Introduction to Jazz Piano",
"category": "music",
"tags": "jazz,piano,tutorial"
}
}
```
**Response:** `201 Created` (no body)
#### `POST /embeddings`
Write an embedding vector for an item.
```json
{
"entity_id": 1,
"values": [0.1, 0.2, 0.3, ...]
}
```
**Response:** `204 No Content`
#### `POST /signals`
Record an engagement signal. `user_id` and `creator_id` are optional — when provided, the signal updates user preferences and interaction weights (equivalent to `signal_with_context` in the library API).
```json
{
"entity_id": 1,
"signal": "view",
"weight": 1.0,
"user_id": 123,
"creator_id": 100
}
```
**Response:** `204 No Content`
### Query Endpoints
#### `GET /feed`
Retrieve a ranked feed.
| Parameter | Type | Default | Description |
|---|---|---|---|
| `user_id` | `u64` | — | User for personalization (optional) |
| `profile` | `string` | `"for_you"` | Ranking profile name |
| `limit` | `u32` | `20` | Max results |
```
GET /feed?user_id=123&profile=trending&limit=25
```
**Response:**
```json
{
"items": [
{
"entity_id": 42,
"score": 0.95,
"rank": 1,
"signals": [
{"name": "view", "value": 1523.0},
{"name": "like", "value": 89.0}
]
}
],
"total_candidates": 1000
}
```
The `signals` field is omitted when empty.
#### `GET /search`
Text search with optional personalization.
| Parameter | Type | Default | Description |
|---|---|---|---|
| `query` | `string` | — | Search text (**required**) |
| `user_id` | `u64` | — | User for personalization (optional) |
| `limit` | `u32` | `20` | Max results |
```
GET /search?query=jazz+piano&user_id=123&limit=10
```
**Response:**
```json
{
"items": [
{
"entity_id": 42,
"score": 0.88,
"rank": 1,
"bm25_score": 12.5,
"semantic_score": 0.92
}
],
"total_candidates": 50
}
```
`bm25_score` and `semantic_score` are omitted when not applicable.
### Health Endpoints
| Endpoint | Auth | Description |
|---|---|---|
| `GET /health` | No | Readiness probe — 200 when ready, 503 when shutting down |
| `GET /health/startup` | No | Startup probe — always 200 |
| `GET /health/live` | No | Liveness probe — always 200 |
| `GET /openapi.json` | No | Machine-readable OpenAPI 3.1 spec for this server (data + cluster routes) |
These map directly to Kubernetes startup/liveness/readiness probes — see
[docs/runbooks/kubernetes.md](docs/runbooks/kubernetes.md).
**`GET /openapi.json`** returns the canonical, served HTTP API contract. It is
generated from the handlers (so it never drifts from the code) and is exempt
from auth — clients need it to learn how to authenticate. Load it into any
OpenAPI viewer or generate a client:
```bash
curl -s http://localhost:9400/openapi.json | jq '.info, (.paths | keys)'
```
The cluster server serves a superset document that also includes the
`/cluster/*` and `/sharded/*` routes. See
[docs/guides/server-deployment.md](docs/guides/server-deployment.md).
**`GET /health` response:**
```json
{"ok": true, "service": "tidaldb", "mode": "standalone", "items": 1234}
```
During shutdown:
```json
HTTP/1.1 503 Service Unavailable
{"ok": false, "service": "tidaldb", "cause": "shutting down"}
```
### Error Responses
All errors return JSON with an `error` field:
```json
{"error": "description of what went wrong"}
```
| Condition | HTTP Status |
|---|---|
| Invalid input, bad schema | `400 Bad Request` |
| Missing/invalid API key | `401 Unauthorized` |
| Policy violation, expired session | `403 Forbidden` |
| Entity not found | `404 Not Found` |
| Request timeout | `408 Request Timeout` |
| Body too large | `413 Payload Too Large` |
| Backpressure, rate limited | `429 Too Many Requests` |
| Internal error | `500 Internal Server Error` |

View File

@ -4,45 +4,6 @@ All notable changes to tidalDB will be documented in this file.
## [Unreleased]
### Added
**M9 — Community Sync & Revocation**
- Local embeddable profiles can opt into community personalization and safely leave/purge their contributions. New types: `SignalScope`, `CommunityId`, `Membership`, `MembershipEpoch`, `PolicyMetadata`. Community signal reconciliation via `CrdtSignalState` with commutative/associative/idempotent merge laws; membership-epoch revocation purges a departed member's contributed signals.
**M10 — Governance & Agent Rights**
- Community rules and agent-scoped permissions control what signals influence ranking: policy-metadata enforcement and agent-rights scoping wired into the signal-write and ranking paths.
### Changed
**Cluster mode (m8p8): real gRPC replication**
- `tidal-server`'s `ClusterState` now wires each follower region to a real
`tidal-net` `GrpcTransport` (self-loop over loopback gRPC) instead of in-process
crossbeam channels — replication between regions traverses real gRPC/TCP
(serialization, circuit breaker, HTTP/2). Closes M8 gap G1.
- Follower gRPC ports are auto-allocated from the topology (`grpc_addr` optional
per region) and self-heal a transient bind race by retrying on a fresh port.
- Blocking gRPC ships triggered by `POST /signals` and `POST /cluster/heal` are
offloaded to a dedicated thread so they never block the axum reactor.
- `ClusterState` is constructed off the async reactor (`GrpcTransport::new`
blocks on its own runtime).
- The experimental opt-in gate and `docker/cluster/Dockerfile` are updated:
cluster mode is honest that it replicates over real gRPC but still runs all
regions in one process (no host/process isolation — true multi-process
deployment remains m8p10).
**Docker build fixes** (all three images now that `tidal-server` pulls `tidal-net`)
- Install `protobuf-compiler` in the builder stage — `tidal-net`'s build script
runs `tonic-build`, which needs `protoc` to compile the WAL-shipping `.proto`.
- Pin the builder base to `bookworm` (`rust:1.91-bookworm` /
`rust:1.91-slim-bookworm`) so its glibc matches the `debian:bookworm-slim`
runtime; the default trixie base emitted a `libmvec.so.1` dependency absent on
bookworm, aborting the binary at startup. Verified: `docker run` of the cluster
image serves a functional 3-region cluster (write replicates to both followers
over gRPC; region-pinned reads serve replicated data; SIGTERM exits 0).
- New tests: `tidal-server/tests/cluster_grpc.rs` (in-process gRPC replication +
HTTP offload path) and a hardened tier-3 `cluster_e2e.rs` (multi-process smoke
+ promote over real OS processes, feature-gated).
## [0.1.0] - 2026-02-23
### Added

View File

@ -5,17 +5,13 @@
A single-node-first, embeddable Rust database for the **personalized content ranking problem**. Replaces the 6-system stack (Elasticsearch + Redis + Kafka + feature store + vector DB + ranking service) with a single process, single query interface, and single operational model.
**Status:** Implemented — M0M10 shipped (embeddable engine + multi-region cluster mode). This repository is a standalone Cargo workspace: the engine is the `tidaldb` crate at `tidal/`, with `tidal-net/`, `tidal-server/`, and `tidalctl/` as workspace siblings and example consumers under `applications/`. Pre-1.0 — APIs are stable for shipped features, but breaking changes are possible before 1.0. See [CHANGELOG.md](CHANGELOG.md) for milestone history and [docs/planning/ROADMAP.md](docs/planning/ROADMAP.md) for status and known gaps.
**Status:** Vision and specification phase. No implementation yet.
## Find Your Guide
| If you need to... | Read this |
|-------------------|-----------|
| **Get started quickly** | [README.md](README.md) → [QUICKSTART.md](QUICKSTART.md) |
| **Build a real app (feed)** | [docs/guides/build-a-feed-app.md](docs/guides/build-a-feed-app.md) |
| **Wire an embedding model** | [docs/guides/embeddings.md](docs/guides/embeddings.md) |
| **Run / deploy the HTTP server** | [docs/guides/server-deployment.md](docs/guides/server-deployment.md) |
| **Deploy on Kubernetes** | [docs/runbooks/kubernetes.md](docs/runbooks/kubernetes.md) + [k8s/](k8s/) |
| **Understand the vision** | [VISION.md](VISION.md) |
| **See use cases and surfaces** | [USE_CASES.md](USE_CASES.md) |
| **See sequence diagrams** | [SEQUENCE.md](SEQUENCE.md) |
@ -24,29 +20,16 @@ A single-node-first, embeddable Rust database for the **personalized content ran
| **Follow coding standards** | [CODING_GUIDELINES.md](CODING_GUIDELINES.md) |
| **See the API spec** | [API.md](API.md) |
| **Read architectural lessons** | [thoughts.md](thoughts.md) |
| **Read the component specs** | [docs/specs/](docs/specs/) (0014) |
| **Browse all engineering docs** | [docs/README.md](docs/README.md) (index of specs, planning, research, reviews, ops, runbooks, profiling) |
| **See the roadmap / milestone history** | [docs/planning/ROADMAP.md](docs/planning/ROADMAP.md), [CHANGELOG.md](CHANGELOG.md) |
| **Read code-review findings** | [docs/reviews/](docs/reviews/) |
| **Operate / monitor in production** | [docs/ops/](docs/ops/), [docs/runbooks/](docs/runbooks/) |
| **Read technical research** | [docs/research/](docs/research/) |
| **Contribute** | [CONTRIBUTING.md](CONTRIBUTING.md) |
## Agents
This is the canonical agent roster. `AGENTS.md` mirrors it for tools that read that file; keep the two in sync.
| Agent | Identity | Model | Use when |
|-------|----------|-------|----------|
| **@tidal-engineer** | Jon Gjengset | opus | Implementing features, designing storage internals, building the signal system, debugging correctness issues |
| **@tidal-visionary** | Spencer Kimball | opus | Planning roadmaps, defining milestones, scoping phases, making build-vs-defer decisions |
| **@tidal-researcher** | Andy Pavlo | opus | Investigating best practices, surveying prior art, evaluating libraries, producing research documents |
| **@tidal-distributed** | Kyle Kingsbury | opus | Building network transports, cluster coordination, multi-node deployment, cross-node query routing, HA |
| **@tidal-storyteller** | — | sonnet | Building the marketing site, writing blog posts, crafting public-facing copy |
**Utility agent:** `@knowledge-librarian` (sonnet) — classifies, cross-references, and maintains the project knowledge base.
**Vendored team (for the `applications/` consumers, not the database):** `@kai-park` (Aeries full-stack engineer), `@kaya-osei` (Aeries product designer), and `@mira-vasquez` (Aeries product visionary) back the `aeries-*` skills. They are scoped to companion-app work, not the tidalDB engine.
| Agent | Identity | Use when |
|-------|----------|----------|
| **@tidal-engineer** | Jon Gjengset | Implementing features, designing storage internals, building the signal system, debugging correctness issues |
| **@tidal-visionary** | Spencer Kimball | Planning roadmaps, defining milestones, scoping phases, making build-vs-defer decisions |
| **@tidal-researcher** | Andy Pavlo | Investigating best practices, surveying prior art, evaluating libraries, producing research documents |
| **@tidal-storyteller** | — | Building the marketing site, writing blog posts, crafting public-facing copy |
## Skills
@ -70,7 +53,6 @@ This is the canonical agent roster. `AGENTS.md` mirrors it for tools that read t
| `/roadmap` | Building or updating the milestone roadmap (delegates to @tidal-visionary) |
| `/build-site` | Creating or iterating on the marketing site |
| `/write-blog` | Writing blog posts about progress or architecture |
| `/distribute` | Building multi-node distributed system (network transport, cluster mode, cross-node queries) |
## Core Domain Model
@ -91,50 +73,43 @@ Dev servers use port range **5952059529** (e.g. `site/` on 59520).
- **Signals are primitives:** Decay, velocity, and windowed aggregation are native — not application logic.
- **Single-node first:** Embeddable. Scales vertically before horizontally.
- **Language:** Rust.
- **Docs have two canonical homes:** top-level `*.md` and `docs/`. Edit the canonical file — never a per-crate mirror. `.sdlc/` is live SDLC tooling and `applications/*/` docs belong to those consumer products; neither is part of the database doc set.
## Repository Structure
This repository is a standalone Cargo workspace (members: `tidal`, `tidal-net`, `tidalctl`,
`tidal-server`, and the `applications/` consumers). **Documentation has exactly two homes:**
the top-level `*.md` files and `docs/`. Do not create per-crate doc mirrors (e.g. `tidal/docs/`,
`tidal/ai-lookup/`, `tidal/site/`) — those were a stale duplicate and were consolidated away.
```
. # Workspace root — canonical docs + config
├── Cargo.toml # Workspace manifest (8 members)
├── CLAUDE.md AGENTS.md README.md CONTRIBUTING.md CHANGELOG.md
├── VISION.md USE_CASES.md SEQUENCE.md ARCHITECTURE.md
├── API.md QUICKSTART.md CODING_GUIDELINES.md thoughts.md
├── ai-lookup/ # Domain concept reference (index.md + features/ + services/)
├── docs/ # Engineering docs (see docs/README.md for the index)
│ ├── specs/ # 0014 component specifications
│ ├── planning/ # ROADMAP.md + per-milestone phase/task archive
│ ├── research/ # Deep technical research docs
│ ├── reviews/ # Code-review passes
│ ├── ops/ # Monitoring, alerts, capacity planning
│ ├── runbooks/ # Operational runbooks (cluster, recovery)
│ └── profiling/ # Flamegraph + scale profiling notes
├── .claude/ # Claude Code config — agents/ and skills/ (canonical; .agents/ removed)
├── tidal/ # The `tidaldb` engine crate (CLAUDE.md here is a thin crate pointer)
. # Top-level docs and configuration
├── CLAUDE.md # This file — project instructions
├── VISION.md # Product vision and thesis
├── USE_CASES.md # 14 use cases, all discovery surfaces
├── SEQUENCE.md # Data flow sequence diagrams
├── CODING_GUIDELINES.md # Engineering standards
├── API.md # API specification
├── thoughts.md # Architectural lessons from sister projects
├── ai-lookup/ # Domain concept reference
├── docs/ # Research and documentation
│ └── research/ # Deep technical research docs
├── .claude/ # Claude Code configuration
│ ├── agents/ # Agent definitions
│ └── skills/ # Skill definitions
├── tidal/ # Rust database engine
│ ├── Cargo.toml
│ ├── src/ # cohort, db, entities, governance, load, query, ranking,
│ │ # replication, schema, session, signals, storage, testing, text, wal
│ ├── benches/ examples/ tests/ docker/
├── tidal-net/ # Network transport primitives (gRPC, WAL shipping)
├── tidal-server/ # Standalone Axum HTTP server (standalone + cluster modes)
├── tidalctl/ # CLI for inspecting persisted databases
├── applications/ # Example consumers (forage, iknowyou)
└── site/ # Public marketing site (Next.js, dev on 59520)
│ ├── src/
│ │ ├── storage/ # Entity store, signal ledger, inverted index, HNSW
│ │ ├── query/ # Query parser, planner, executor
│ │ ├── ranking/ # Profile engine, signal scoring, diversity enforcement
│ │ ├── signals/ # Signal types, decay, velocity, windowed aggregation
│ │ └── schema/ # Schema definition, validation, migrations
│ ├── benches/ # Performance benchmarks
│ └── tests/ # Integration and property tests
└── site/ # Public marketing site (Next.js)
```
## Pre-commit Hooks
The pre-commit hook runs automatically on staged files:
- **Rust:** `cargo fmt` (auto-fix + re-stage), `cargo clippy -p tidaldb -D warnings`, `cargo test -p tidaldb --lib`
- **tidal/ (Rust):** `cargo fmt` (auto-fix + re-stage), `cargo clippy -D warnings`, `cargo test --lib`
- **site/ (Next.js):** `eslint` (if node_modules installed)
- **Docs:** `scripts/check-docs.sh` — fails if a doc mirror reappears (`tidal/docs/`, `.ai/`, `.agents/skills/`), if CLAUDE.md's workspace members drift from `Cargo.toml`, or if a canonical cross-reference breaks.
Cargo commands target the engine crate with `-p tidaldb` from the workspace root (equivalently `--manifest-path tidal/Cargo.toml`).
All cargo commands use `--manifest-path tidal/Cargo.toml` since the Rust project is not at repo root.
**Tests must be fast.** Slow or hanging tests are bugs — diagnose root cause, then remove, fix, or refactor; never leave them hanging.

View File

@ -140,19 +140,10 @@ Never hardcode a single filtering strategy. Estimate selectivity, then branch:
The entity store is the source of truth. Tantivy is a materialized view. If the Tantivy index is corrupted or lost, it can be rebuilt from the entity store.
Consistency pattern:
1. Write to entity store (within transaction / WAL) — the durable source of truth.
2. Background indexer reads the outbox and feeds Tantivy (best-effort, async).
3. On crash recovery, unconditionally rebuild the text index from the entity
store at open — the same proven pattern as the vector index, which is rebuilt
from the durable embeddings on every reopen.
> Crash recovery is NOT driven by a Tantivy commit-payload sequence number.
> Because the indexer is best-effort and async, an item can be durably persisted
> to the entity store and then lost in a crash before the syncer commits it to
> Tantivy. A stored sequence number does not heal that gap by itself and is
> easy to leave un-wired (it once silently was, hiding the lost write). The
> unconditional rebuild-from-store at open is deterministic and cannot silently
> regress: any entity in the store but missing from Tantivy is re-indexed.
1. Write to entity store (within transaction / WAL)
2. Background indexer reads outbox and feeds Tantivy
3. On each Tantivy commit, store last-processed sequence number in commit payload
4. On crash recovery, replay from that sequence number
### Hybrid fusion starts with RRF

View File

@ -62,17 +62,27 @@ cargo bench --manifest-path tidal/Cargo.toml --no-run # ensure benches compi
## Project Layout
This is a Cargo workspace — the `tidaldb` engine crate is at `tidal/`, with `tidal-net/`,
`tidal-server/`, and `tidalctl/` as siblings and example consumers under `applications/`.
See **[CLAUDE.md § Repository Structure](CLAUDE.md#repository-structure)** for the full,
canonical layout (including the `tidal/src/` module map and the `docs/` doc homes).
```
tidal/ Rust database engine
src/
db/ TidalDb handle, builder, config, metrics
schema/ Types, validation, error types
signals/ Signal ledger, decay, windowed counters, checkpoint
storage/ StorageEngine trait, fjall backend, key encoding
wal/ Write-ahead log, group commit, crash recovery
benches/ Criterion benchmarks
examples/ Embedding guides (quickstart, axum, actix, cli)
tests/ Integration and property tests
site/ Marketing site (Next.js)
docs/ Research and planning documents
```
## Coding Standards
See [CODING_GUIDELINES.md](CODING_GUIDELINES.md) for the full engineering standards.
Key rules:
- `Result<T, TidalError>` everywhere — no panics on recoverable failures
- `Result<T, LumenError>` everywhere — no panics on recoverable failures
- `#![forbid(unsafe_code)]` — relaxed only at explicit FFI boundaries with `// SAFETY:` comment
- Property tests for invariants, criterion benchmarks for performance claims
- `cargo clippy -D warnings` must pass with zero warnings

420
Cargo.lock generated
View File

@ -313,28 +313,6 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-stream"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
dependencies = [
"async-stream-impl",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-stream-impl"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "async-trait"
version = "0.1.89"
@ -358,28 +336,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]]
name = "axum"
version = "0.7.9"
@ -408,7 +364,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower 0.5.3",
"tower",
"tower-layer",
"tower-service",
"tracing",
@ -441,7 +397,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower 0.5.3",
"tower",
"tower-layer",
"tower-service",
"tracing",
@ -729,15 +685,6 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "codespan-reporting"
version = "0.13.1"
@ -972,7 +919,7 @@ checksum = "b0f4697d190a142477b16aef7da8a99bfdc41e7e8b1687583c0d23a79c7afc1e"
dependencies = [
"cc",
"codespan-reporting",
"indexmap 2.13.0",
"indexmap",
"proc-macro2",
"quote",
"scratch",
@ -987,7 +934,7 @@ checksum = "d0956799fa8678d4c50eed028f2de1c0552ae183c76e976cf7ca8c4e36a7c328"
dependencies = [
"clap",
"codespan-reporting",
"indexmap 2.13.0",
"indexmap",
"proc-macro2",
"quote",
"syn",
@ -1005,7 +952,7 @@ version = "1.0.194"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6acc6b5822b9526adfb4fc377b67128fdd60aac757cc4a741a6278603f763cf"
dependencies = [
"indexmap 2.13.0",
"indexmap",
"proc-macro2",
"quote",
"syn",
@ -1106,12 +1053,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.15.0"
@ -1173,12 +1114,6 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "fjall"
version = "3.0.2"
@ -1308,12 +1243,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures-channel"
version = "0.3.32"
@ -1425,7 +1354,7 @@ dependencies = [
"futures-sink",
"futures-util",
"http 0.2.12",
"indexmap 2.13.0",
"indexmap",
"slab",
"tokio",
"tokio-util",
@ -1444,7 +1373,7 @@ dependencies = [
"futures-core",
"futures-sink",
"http 1.4.0",
"indexmap 2.13.0",
"indexmap",
"slab",
"tokio",
"tokio-util",
@ -1462,12 +1391,6 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.5"
@ -1611,19 +1534,6 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "hyper-timeout"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
dependencies = [
"hyper",
"hyper-util",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
@ -1818,16 +1728,6 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2"
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
]
[[package]]
name = "indexmap"
version = "2.13.0"
@ -1984,16 +1884,6 @@ dependencies = [
"libc",
]
[[package]]
name = "libyml"
version = "0.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980"
dependencies = [
"anyhow",
"version_check",
]
[[package]]
name = "link-cplusplus"
version = "1.0.12"
@ -2189,12 +2079,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "multimap"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]]
name = "murmurhash32"
version = "0.3.1"
@ -2363,52 +2247,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64",
"serde_core",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
dependencies = [
"fixedbitset",
"indexmap 2.13.0",
]
[[package]]
name = "pin-project"
version = "1.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "pin-project-lite"
version = "0.2.16"
@ -2517,58 +2361,6 @@ dependencies = [
"unarray",
]
[[package]]
name = "prost"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
dependencies = [
"bytes",
"prost-derive",
]
[[package]]
name = "prost-build"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
dependencies = [
"heck",
"itertools 0.12.1",
"log",
"multimap",
"once_cell",
"petgraph",
"prettyplease",
"prost",
"prost-types",
"regex",
"syn",
"tempfile",
]
[[package]]
name = "prost-derive"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
"itertools 0.12.1",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "prost-types"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16"
dependencies = [
"prost",
]
[[package]]
name = "quick-error"
version = "1.2.3"
@ -2753,19 +2545,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "rcgen"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
dependencies = [
"pem",
"ring",
"rustls-pki-types",
"time",
"yasna",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@ -2857,7 +2636,7 @@ dependencies = [
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tower 0.5.3",
"tower",
"tower-http 0.6.8",
"tower-service",
"url",
@ -2954,8 +2733,6 @@ version = "0.23.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
"rustls-pki-types",
@ -2964,27 +2741,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
@ -3001,7 +2757,6 @@ version = "0.103.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@ -3163,18 +2918,16 @@ dependencies = [
]
[[package]]
name = "serde_yml"
version = "0.0.12"
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.13.0",
"indexmap",
"itoa",
"libyml",
"memchr",
"ryu",
"serde",
"version_check",
"unsafe-libyaml",
]
[[package]]
@ -3562,53 +3315,29 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "tidal-net"
version = "0.1.0"
dependencies = [
"criterion",
"prost",
"rcgen",
"rustls",
"tempfile",
"thiserror 2.0.18",
"tidaldb",
"tokio",
"tokio-stream",
"tonic",
"tonic-build",
"tracing",
]
[[package]]
name = "tidal-server"
version = "0.1.0"
dependencies = [
"axum 0.8.8",
"clap",
"crossbeam",
"reqwest",
"serde",
"serde_json",
"serde_yml",
"serde_yaml",
"subtle",
"tempfile",
"thiserror 2.0.18",
"tidal-net",
"tidaldb",
"tokio",
"tower 0.5.3",
"tower",
"tower-http 0.6.8",
"tracing",
"tracing-subscriber",
"utoipa",
]
[[package]]
name = "tidalctl"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"tidaldb",
]
@ -3630,7 +3359,6 @@ dependencies = [
"proptest",
"rand 0.9.2",
"roaring",
"rustix 1.1.3",
"serde",
"serde_json",
"tantivy",
@ -3781,73 +3509,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "tonic"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52"
dependencies = [
"async-stream",
"async-trait",
"axum 0.7.9",
"base64",
"bytes",
"h2 0.4.13",
"http 1.4.0",
"http-body",
"http-body-util",
"hyper",
"hyper-timeout",
"hyper-util",
"percent-encoding",
"pin-project",
"prost",
"rustls-native-certs",
"rustls-pemfile",
"socket2 0.5.10",
"tokio",
"tokio-rustls",
"tokio-stream",
"tower 0.4.13",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tonic-build"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11"
dependencies = [
"prettyplease",
"proc-macro2",
"prost-build",
"prost-types",
"quote",
"syn",
]
[[package]]
name = "tower"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
dependencies = [
"futures-core",
"futures-util",
"indexmap 1.9.3",
"pin-project",
"pin-project-lite",
"rand 0.8.5",
"slab",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower"
version = "0.5.3"
@ -3904,7 +3565,7 @@ dependencies = [
"iri-string",
"pin-project-lite",
"tokio",
"tower 0.5.3",
"tower",
"tower-layer",
"tower-service",
"tracing",
@ -4039,6 +3700,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
@ -4085,30 +3752,6 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "utoipa"
version = "5.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160"
dependencies = [
"indexmap 2.13.0",
"serde",
"serde_json",
"utoipa-gen",
]
[[package]]
name = "utoipa-gen"
version = "5.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn",
]
[[package]]
name = "uuid"
version = "1.21.0"
@ -4273,7 +3916,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap 2.13.0",
"indexmap",
"wasm-encoder",
"wasmparser",
]
@ -4286,7 +3929,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap 2.13.0",
"indexmap",
"semver",
]
@ -4604,7 +4247,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap 2.13.0",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
@ -4635,7 +4278,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap 2.13.0",
"indexmap",
"log",
"serde",
"serde_derive",
@ -4654,7 +4297,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap 2.13.0",
"indexmap",
"log",
"semver",
"serde",
@ -4676,15 +4319,6 @@ version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]]
name = "yoke"
version = "0.8.1"

View File

@ -1,7 +1,6 @@
[workspace]
members = [
"tidal",
"tidal-net",
"tidalctl",
"tidal-server",
"applications/forage/engine",

View File

@ -20,12 +20,9 @@ The rest of this guide explains what it does and extends it with personalization
## Step 1: Add the dependency
Depend on the `tidaldb` crate (the `tidal/` crate in this repository) by git or by path:
```toml
[dependencies]
tidaldb = { git = "https://github.com/orchard9/tidaldb", rev = "..." }
# or, for a local checkout: tidaldb = { path = "path/to/tidaldb/tidal" }
tidaldb = { git = "https://github.com/your-org/tidalDB", rev = "..." }
```
---
@ -42,18 +39,14 @@ let mut schema = SchemaBuilder::new();
// View signal: 7-day half-life, three windows, velocity enabled.
// You declare the decay. tidalDB applies it at query time — no formula to maintain.
// `positive_engagement(true)` is what makes this signal fold into a user's taste
// vector when you record it with context (Step 7) — without it, personalization
// falls back to name-based heuristics. Declare it explicitly on signals that
// should shape preferences.
let _ = schema.signal("view", EntityKind::Item, DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
}).windows(&[Window::OneHour, Window::TwentyFourHours, Window::AllTime]).velocity(true).positive_engagement(true).add();
}).windows(&[Window::OneHour, Window::TwentyFourHours, Window::AllTime]).velocity(true).add();
// Like signal: 30-day half-life. Durable engagement decays slowly.
let _ = schema.signal("like", EntityKind::Item, DecaySpec::Exponential {
half_life: Duration::from_secs(30 * 24 * 3600),
}).windows(&[Window::AllTime]).velocity(false).positive_engagement(true).add();
}).windows(&[Window::AllTime]).velocity(false).add();
// Share signal: 3-day half-life. Short-lived but strongly trending.
let _ = schema.signal("share", EntityKind::Item, DecaySpec::Exponential {
@ -292,19 +285,12 @@ This flushes the WAL, checkpoints signal state, and persists indexes. In persist
| Topic | Where to look |
|-------|--------------|
| **Build a real app (TikTok-style feed)** | [docs/guides/build-a-feed-app.md](docs/guides/build-a-feed-app.md) |
| **Wire a real embedding model** | [docs/guides/embeddings.md](docs/guides/embeddings.md) |
| **Run the HTTP server / deploy** | [docs/guides/server-deployment.md](docs/guides/server-deployment.md) |
| **Deploy on Kubernetes** | [docs/runbooks/kubernetes.md](docs/runbooks/kubernetes.md) |
| Full API reference | [API.md](API.md) |
| HTTP API contract (served, machine-readable) | `GET /openapi.json` (see server-deployment guide) |
| Filters — format, duration, location, engagement thresholds | [API.md — Filters](API.md#filters) |
| Diversity constraints | [API.md — Diversity Constraints](API.md#diversity-constraints) |
| All 25 ranking profiles | [ai-lookup/services/ranking-profiles.md](ai-lookup/services/ranking-profiles.md) |
| All 25 ranking profiles | [API.md — Sort Modes](API.md#sort-modes) |
| Cohort-scoped trending | [API.md — Cohorts](API.md#cohort-definitions) |
| Collections and saved searches | [API.md — Collections](API.md#collections) |
| Short-video feed example | `tidal/examples/foryou_feed.rs` |
| Axum embedding example | `tidal/examples/axum_embedding.rs` |
| 14 content discovery surfaces | [USE_CASES.md](USE_CASES.md) |
| Architecture and design decisions | [ARCHITECTURE.md](ARCHITECTURE.md) |
| Cluster mode (experimental) | [docs/runbooks/cluster.md](docs/runbooks/cluster.md) |

View File

@ -95,11 +95,10 @@ Pick the path that matches how you plan to use tidalDB today. Every option below
**Setup**
1. Add the dependency (the `tidaldb` crate is at `tidal/` in this repository):
1. Add the git dependency:
```toml
[dependencies]
tidaldb = { git = "https://github.com/orchard9/tidaldb", rev = "..." }
# or, for a local checkout: tidaldb = { path = "path/to/tidaldb/tidal" }
tidaldb = { git = "https://github.com/your-org/tidalDB", rev = "..." }
```
2. Define your schema before opening the database (decay, windows, text fields, embeddings). The snippet in **[Quickstart, Step 2](QUICKSTART.md#step-2-define-a-schema)** is a ready-to-copy template.
3. Choose storage mode when building:
@ -210,7 +209,7 @@ curl -X POST http://localhost:4242/signal \
-d '{ "user_id": 1, "item_id": 42, "signal_type": "view" }'
curl "http://localhost:4242/feed?user=1&limit=7"
```
The UI shows seeded users, exploration labels, and real-time adaptation; see `applications/forage/README.md` for the full loop.
The UI shows seeded users, exploration labels, and real-time adaptation; see `applications/forage/readme.md` for the full loop.
### 5. Run the cluster server + Docker image
@ -275,10 +274,6 @@ migration scenarios inside your own test suites.
|----------|----------|
| [QUICKSTART.md](QUICKSTART.md) | Step-by-step guide: schema, ingest, signals, ranking, search |
| [API.md](API.md) | Full API reference with code examples |
| [Build a feed app](docs/guides/build-a-feed-app.md) | End-to-end TikTok/Reels-style "For You" feed tutorial |
| [Embedding integration](docs/guides/embeddings.md) | Wiring a real embedding model into the write + query paths |
| [Server deployment](docs/guides/server-deployment.md) | Running `tidal-server`: config, auth, OpenAPI, Docker |
| [Kubernetes runbook](docs/runbooks/kubernetes.md) | Deploying on k8s (manifests in [`k8s/`](k8s/)) |
| [VISION.md](VISION.md) | Problem statement and design thesis |
| [ARCHITECTURE.md](ARCHITECTURE.md) | Storage, signal system, vector index, query pipeline |
| [USE_CASES.md](USE_CASES.md) | 14 content discovery surfaces, filter and sort references |

View File

@ -4,7 +4,7 @@
|-------|------|------------|---------|---------|
| Entities | `services/entities.md` | High | 2026-02-19 | Items, Users, Creators — core data model |
| Signals | `services/signals.md` | High | 2026-02-19 | Typed event streams with decay, velocity, windowed aggregation |
| Ranking Profiles | `services/ranking-profiles.md` | High | 2026-06-09 | All 25 built-in profiles: strategy + sort + diversity + UC map |
| Ranking Profiles | `services/ranking-profiles.md` | High | 2026-02-19 | Named scoring functions declared in schema |
| Query Language | `features/query-language.md` | High | 2026-02-19 | RETRIEVE/SEARCH/SIGNAL query surface |
| Sort Modes | `features/sort-modes.md` | High | 2026-02-19 | 25+ native sort modes (hot, trending, rising, etc.) |
| Filters | `features/filters.md` | High | 2026-02-19 | Composable filter dimensions across all queries |

View File

@ -1,115 +1,38 @@
# Ranking Profiles
**Last Updated:** 2026-06-09
**Last Updated:** 2026-02-19
**Confidence:** High
## Summary
A ranking profile is a named, versioned bundle that fully specifies how a query turns candidates into an ordered result page: it names the **retrieval strategy** (how candidates are sourced — scan, ANN, signal-ranked, relationship, hybrid, cohort), the **primary sort** (the ordering formula — trending, hot, top-window, etc.), any **boosts / gates / penalties / excludes** (signal-driven score adjustments and filters), the **diversity** rules (per-creator caps and format-mix caps), and an **exploration** fraction (random injection to break filter bubbles). Profiles are selected by name at query time (`.profile("for_you")`), declared in schema, and versioned alongside data — old versions remain queryable. tidalDB ships **25 built-in profiles** registered at `version = 1` with `is_builtin = true`; applications add their own with the schema builder or the server config YAML.
Ranking profiles are named, versioned scoring functions declared in schema. They reference signals, relationship weights, recency curves, and diversity rules. Profiles live in the database, are versioned alongside data, and can be swapped at query time by name.
**Key Facts:**
- Profiles are schema-level declarations, not application code. The app names a profile; the database executes the whole pipeline.
- The same profile operates over different candidate sets (global / category / social graph / cohort) depending on its candidate strategy and the query's filters.
- Personalization (`for_you`, `following`, `related`, `notification`, `date_saved`) requires `FOR USER` context — pass `.for_user(uid)` on the query. Without it the executor cannot read the user's preference vector / saved-state and either degrades to non-personalized scoring or, for `date_saved`, returns an error.
- **Preference-vector personalization only updates from signals declared `.positive_engagement(true)`.** `signal_with_context(..)` folds the item embedding into the user's taste vector only for positive-engagement signal types; negative signals (skip/hide/block) update seen-state and hard-negatives but do not pull the taste vector toward the item. Declare engagement signals accordingly or `for_you` will not learn.
- Built-in defaults below are read straight from `tidal/src/ranking/builtins.rs`. Where a value is intentionally application-tuning territory, it is described qualitatively rather than invented.
- Profiles are schema-level declarations, not application code
- Each profile defines: primary signals, secondary signals, boosts, gates, and diversity rules
- The same profile can operate on different candidate sets (global vs category vs social graph)
- Profiles are versioned — old versions remain queryable
**File Pointers:** `tidal/src/ranking/builtins.rs` (the 25 built-ins), `tidal/src/ranking/profile.rs` (`RankingProfile`, `DiversitySpec`, `Sort`, `Boost`, `CandidateStrategy`), `VISION.md:43-55`.
**File Pointer:** `VISION.md:43-55`
## The 25 Built-in Profiles
## Built-in Profiles
`DiversitySpec` defaults to **no diversity** (`max_per_creator = None`, `format_mix_max_fraction = None`) and `exploration` defaults to `0.0`. Profiles below list only the values they override. "Needs FOR USER" marks profiles whose ordering or scoring depends on per-user context.
| Profile | Optimizes for | Primary sort | Diversity / exploration (defaults) | Needs FOR USER | Use case |
|---------|---------------|--------------|------------------------------------|----------------|----------|
| `trending` | Pure short-window engagement velocity (`view_vel + 2·share_vel`, 24h) | `Trending` | `max_per_creator=1`; no exploration | No | UC-03 |
| `hot` | Cumulative score decayed by age (Reddit/HN style) | `Hot { gravity = 1.8 }` | `max_per_creator=2`; no exploration | No | UC-14 |
| `new` | Freshest content first | `New` (`created_at DESC`) | none | No | UC-03, UC-04 |
| `for_you` | Personalized home feed (decayed view/like + 24h share velocity, on top of preference match) | `Hot { gravity = 1.5 }` | `max_per_creator=2`, `format_mix_max_fraction=0.4`, `exploration=0.1` | Yes | UC-01 |
| `following` | Recent content from creators the user follows | `New` | `max_per_creator=3`; no exploration | Yes | UC-04 |
| `related` | "More like this" for a seed item (semantic similarity + completion-weighted quality) | `Hot { gravity = 1.2 }` | `max_per_creator=2`; no exploration | No (use `.similar_to(item_id)`) | UC-05 |
| `notification` | High-velocity content from followed creators, worth interrupting for | `Trending` | `max_per_creator=1`; no exploration | Yes | UC-07 |
| `search` | Text + vector relevance (RRF fusion) with a light view/like quality overlay | `None` (fused RRF score from search executor is the primary order) | none (caller sets diversity); no exploration (deterministic) | No (personalizable via `.for_user`) | UC-02, UC-10, UC-11 |
| `top_week` | Best-of over a 7-day window | `TopWindow { SevenDays }` | none | No | UC-06 |
| `top_month` | Best-of over a 30-day window | `TopWindow { ThirtyDays }` | none | No | UC-06 |
| `top_all_time` | Cumulative quality, no decay (classic / best-of) | `TopWindow { AllTime }` | none | No | UC-06 |
| `hidden_gems` | High quality, low reach (inverse-reach boost) | `HiddenGems` | none | No | UC-13 |
| `controversial` | Maximum product of positive × negative signals (debate surfaces) | `Controversial` | none | No | UC-14 |
| `most_viewed` | Raw view count, 7-day window | `MostViewed { SevenDays }` | none | No | UC-06 |
| `most_liked` | Raw like count, 7-day window | `MostLiked { SevenDays }` | none | No | UC-06 |
| `shuffle` | Quality-weighted random ("surprise me") | `Shuffle` | `exploration=0.5` (half random, half signal-ranked); no creator cap | No | UC-06 |
| `cohort_trending` | Trending scoped to a named audience cohort ("trending for people like you") | `Trending` | `max_per_creator=1`; no exploration | No (use `.cohort("name")`) | UC-15 |
| `live` | Currently-live content by viewer count, lifting creators the user's circle watches | `LiveViewerCount` | `max_per_creator=1`; no exploration | Personalizable (relationship boost uses `.for_user`) | UC-12 |
| `alphabetical_asc` | Title A→Z (case-insensitive) | `AlphabeticalAsc` | none | No | UC-06 |
| `alphabetical_desc` | Title Z→A (case-insensitive) | `AlphabeticalDesc` | none | No | UC-06 |
| `shortest` | Quick content (`duration ASC`) | `Shortest` | none | No | UC-06 |
| `longest` | Deep dives (`duration DESC`) | `Longest` | none | No | UC-06 |
| `most_commented` | Discussion volume (comment count, all-time) | `MostCommented { AllTime }` | none | No | UC-06, UC-14 |
| `most_shared` | Virality (share count, all-time) | `MostShared { AllTime }` | none | No | UC-06 |
| `date_saved` | The user's personal library, latest save first | `DateSaved` | none | **Yes (required)** — missing `FOR USER` returns `InvalidFilter` | UC-09 |
### Notes on specific built-ins
- **`trending` / `cohort_trending` and the `view`/`share` boosts.** The `Sort::Trending` formula (`view_vel + 2·share_vel` over 24h) is the ordering authority on the global path and *replaces* the boost loop, so the executor skips boosts there (no double-count). The same `view`/`share` velocity boosts are not dead weight: the cohort rescore path (`rescore_with_cohort`) consumes `profile.boosts` directly to re-score against a cohort's ledger. Keep the boost weights mirroring the formula so cohort ordering matches global ordering.
- **`for_you` boosts vs. preference match.** Beyond the `Hot { gravity = 1.5 }` recency curve, `for_you` boosts decayed `view` (weight 1.0), decayed `like` (weight 2.0), and 24h `share` velocity (weight 1.5), layered on top of the user-preference-vector match. The 10% exploration injection is what keeps it from collapsing into a filter bubble.
- **`search` has `sort = None` on purpose.** The fused RRF (BM25 + ANN) score from the search executor is the primary ordering signal; the profile only adds a small `view`/`like` quality overlay (weights 0.5 / 0.8) so relevance stays in charge. Exploration is `0.0` so results are deterministic for a given query. Set diversity yourself via `SearchBuilder::diversity(..)`.
- **Three sort modes have no built-in profile shortcut.** `MostFollowed` and `CreatorEngagementRate` rank **creators** (not items) and need a creator-scoped candidate strategy plus a `follow` signal the generic schema does not guarantee; `Rising` (1h/24h view-velocity acceleration ratio) overlaps `trending`/`hot` and is sensitive to the exact window pair. All three are reachable via a custom schema profile (`SchemaBuilder::ranking_profile(..).sort(..)`); only the named-convenience built-in is omitted.
## Candidate (retrieval) strategies
The candidate strategy decides *which* items the profile even considers before sorting:
| Strategy | What it does | Built-ins that use it |
|----------|--------------|-----------------------|
| `Scan` (`sort_field`) | Population scan over the item keyspace; default for built-ins (`sort_field = "created_at"`) | most population + sort-coverage profiles |
| `Relationship` | Sources candidates from the user's relationship graph (followed creators) | `following`, `notification` |
| `Ann { slot, limit }` | k-NN over an embedding slot (set via the query's `.similar_to` / `Search`'s `.vector`) | custom; `related` uses the seed embedding via the query |
| `SignalRanked { signal, window }` | Pre-orders candidates by a signal's windowed aggregate | custom |
| `Hybrid` | Fuses lexical + vector retrieval | the `search` query path (RRF fusion in the search executor) |
| `CohortTrending` | Cohort-scoped signal aggregation | reached via `.cohort("name")` with `cohort_trending` |
> Embeddings are app-supplied. tidalDB retrieves and ranks over vectors but does **not** generate them; `RETRIEVE`/`SEARCH` route ANN through the **first declared embedding slot only** (fuse multi-modal vectors offline or use separate entity kinds). See [API.md](../../API.md) and [QUICKSTART.md](../../QUICKSTART.md).
## Defining a custom profile
Profiles are declared in schema (Rust `SchemaBuilder`) or, for the HTTP server, in the config YAML loaded at startup. The YAML shape mirrors the `RankingProfile` type exactly:
```yaml
profiles:
- name: my_feed
version: 1
# candidate_strategy: scan | { ann: { slot, limit } }
# | { signal_ranked: { signal, window } }
# | relationship | hybrid | cohort_trending
candidate_strategy: scan # default: scan over created_at
# sort: trending | rising | controversial | hidden_gems | shuffle | new
# | { hot: { gravity } } | { top_window: { window } }
# | { most_viewed: { window } } | ...
sort: { hot: { gravity: 1.8 } }
boosts:
# agg: value | velocity | decay_score | ratio | relative_velocity
- { signal: like, agg: decay_score, window: all_time, weight: 2.0 }
- { signal: share, agg: velocity, window: twenty_four_hours, weight: 1.5 }
gates: [] # drop candidates failing a signal threshold
penalties: [] # subtractive score adjustments
excludes: [] # hard removals
diversity:
max_per_creator: 2 # at most 2 items per creator in the page
format_mix_max_fraction: 0.4 # no single format > 40% of the page
exploration: 0.1 # 10% random injection (0.0 = none)
```
Windows are `one_hour`, `twenty_four_hours`, `seven_days`, `thirty_days`, `all_time`. Only signals declared in the same schema may be referenced by boosts/gates — the server validates references at load time and rejects a profile naming an unknown signal. Gates, penalties, and excludes are deliberately omitted from the built-ins because they would assume signals not guaranteed to exist in a generic schema; add them in your own profile once you know your signal set.
In Rust, the equivalent lives behind `SchemaBuilder::ranking_profile(..)`; see [API.md](../../API.md) for the builder surface and the runnable `tidal/examples/foryou_feed.rs` / `tidal/examples/quickstart.rs` for end-to-end usage.
| Profile | Primary Signal | Use Case |
|---------|---------------|----------|
| `for_you` | preference_match + engagement_velocity | Personalized feed |
| `search` | text_relevance + semantic_similarity | Search results |
| `trending` | share_velocity + view_velocity | Trending surfaces |
| `rising` | velocity relative to baseline, age-boosted | Breakout content |
| `following` | created_at DESC | Subscription feed |
| `related` | semantic_similarity + collaborative_filtering | Up next / related |
| `browse` | quality_score (completion + like_ratio + reach) | Category pages |
| `hot` | score / (age + 2)^gravity | Community frontpages |
| `controversial` | max(positive * negative signals) | Debate surfaces |
| `hidden_gems` | high quality, inverse reach | Discovery |
| `notification` | relationship_strength + item quality | Push prioritization |
| `live` | relationship_weight + viewer_count | Live surfaces |
## Related Topics
- [Signals](./signals.md) — the decay/velocity/windowed-aggregation primitives boosts read
- [Entities](./entities.md) — items/users/creators the profiles rank over
- [Query Language](../features/query-language.md) — `RETRIEVE` / `SEARCH` / `SIGNAL` surface
- [Sort Modes](../features/sort-modes.md) — the 25+ native sort formulas profiles select from
- [Filters](../features/filters.md) — composable filter dimensions layered onto any profile
- [USE_CASES.md](../../USE_CASES.md) — UC-01…UC-15 and **Appendix B · Sort Mode Reference**
- [API.md](../../API.md) — builder API + HTTP routes
- [QUICKSTART.md](../../QUICKSTART.md) — first feed in minutes
- [Signals](./signals.md)
- [Query Language](../features/query-language.md)
- [Sort Modes](../features/sort-modes.md)

View File

@ -1640,69 +1640,6 @@ fn canonicalize_url(url: &str) -> String {
}
}
/// Round-robin interleave items by category to ensure the cold-start exploit
/// pool spans ≥3 categories. Preserves score ordering within each category.
///
/// Algorithm: group items by category (preserving input order), then pull
/// one item at a time from each group until `limit` items are collected.
fn interleave_categories(
items: Vec<tidaldb::query::retrieve::RetrieveResult>,
meta_map: &HashMap<u64, SeedItem>,
limit: usize,
) -> Vec<tidaldb::query::retrieve::RetrieveResult> {
use std::collections::BTreeMap;
// Group by category, preserving within-group order (best score first).
let mut by_cat: BTreeMap<String, Vec<tidaldb::query::retrieve::RetrieveResult>> =
BTreeMap::new();
let mut no_meta: Vec<tidaldb::query::retrieve::RetrieveResult> = Vec::new();
for item in items {
if let Some(seed) = meta_map.get(&item.entity_id.as_u64()) {
by_cat.entry(seed.category.clone()).or_default().push(item);
} else {
no_meta.push(item);
}
}
// Round-robin across categories (BTreeMap gives deterministic order).
let mut result = Vec::with_capacity(limit);
let mut iterators: Vec<std::vec::IntoIter<tidaldb::query::retrieve::RetrieveResult>> =
by_cat.into_values().map(|v| v.into_iter()).collect();
if iterators.is_empty() {
// All items lacked metadata; fall back to uncategorized list.
result.extend(no_meta.into_iter().take(limit));
return result;
}
let mut idx = 0;
while result.len() < limit {
let start = idx;
let mut advanced = false;
loop {
if let Some(item) = iterators[idx].next() {
result.push(item);
advanced = true;
}
idx = (idx + 1) % iterators.len();
if idx == start {
break;
}
}
if !advanced {
break; // all iterators exhausted
}
}
// Append any uncategorized items to fill if needed.
for item in no_meta {
if result.len() >= limit {
break;
}
result.push(item);
}
result
}
#[cfg(test)]
mod canon_tests {
use super::canonicalize_url;
@ -1769,3 +1706,66 @@ mod canon_tests {
);
}
}
/// Round-robin interleave items by category to ensure the cold-start exploit
/// pool spans ≥3 categories. Preserves score ordering within each category.
///
/// Algorithm: group items by category (preserving input order), then pull
/// one item at a time from each group until `limit` items are collected.
fn interleave_categories(
items: Vec<tidaldb::query::retrieve::RetrieveResult>,
meta_map: &HashMap<u64, SeedItem>,
limit: usize,
) -> Vec<tidaldb::query::retrieve::RetrieveResult> {
use std::collections::BTreeMap;
// Group by category, preserving within-group order (best score first).
let mut by_cat: BTreeMap<String, Vec<tidaldb::query::retrieve::RetrieveResult>> =
BTreeMap::new();
let mut no_meta: Vec<tidaldb::query::retrieve::RetrieveResult> = Vec::new();
for item in items {
if let Some(seed) = meta_map.get(&item.entity_id.as_u64()) {
by_cat.entry(seed.category.clone()).or_default().push(item);
} else {
no_meta.push(item);
}
}
// Round-robin across categories (BTreeMap gives deterministic order).
let mut result = Vec::with_capacity(limit);
let mut iterators: Vec<std::vec::IntoIter<tidaldb::query::retrieve::RetrieveResult>> =
by_cat.into_values().map(|v| v.into_iter()).collect();
if iterators.is_empty() {
// All items lacked metadata; fall back to uncategorized list.
result.extend(no_meta.into_iter().take(limit));
return result;
}
let mut idx = 0;
while result.len() < limit {
let start = idx;
let mut advanced = false;
loop {
if let Some(item) = iterators[idx].next() {
result.push(item);
advanced = true;
}
idx = (idx + 1) % iterators.len();
if idx == start {
break;
}
}
if !advanced {
break; // all iterators exhausted
}
}
// Append any uncategorized items to fill if needed.
for item in no_meta {
if result.len() >= limit {
break;
}
result.push(item);
}
result
}

View File

@ -1,24 +0,0 @@
# iknowyou
Communication personalization as a signal-processing problem. iknowyou wraps tidalDB's
signal ledger, preference vectors, and windowed aggregation with an observation pipeline
(LM-as-classifier), a briefing engine (query-to-profile), and a generation interface
(brief-to-prompt) — so a system can learn how each person prefers to be communicated with
and adapt continuously, without a training loop or feature store.
> **Design vs shipped.** The vision/architecture docs describe the *target* tidalDB-native
> design; what runs today is a Next.js + vLLM implementation. See the roadmap and dev setup
> for current state.
## Docs
| Doc | What it covers |
|-----|----------------|
| [vision.md](vision.md) | The problem and the product thesis (target design) |
| [architecture.md](architecture.md) | Domain model, pipelines, components (target design) |
| [ROADMAP.md](ROADMAP.md) | Milestones and current build status |
| [devsetup.md](devsetup.md) | Running the engine + Next.js app locally |
| [engine/README.md](engine/README.md) | The personalization engine service |
Part of the tidalDB workspace's `applications/` example consumers — see the
[repository root](../../README.md) for the database itself.

View File

@ -1,12 +1,5 @@
# iknowyou — Architecture
> **Status — design vs shipped.** This document describes the *target* tidalDB-native
> architecture. The currently *shipped* implementation is a Next.js + vLLM stack with the
> personalization engine as a separate service — see [ROADMAP.md](ROADMAP.md) and
> [devsetup.md](devsetup.md) for what runs today. Treat the Rust engine / `server/` design
> below as intent, not a description of the current code. (The shipped engine dev bind is
> `127.0.0.1:7777` per devsetup.md, which sits outside the project's 5952059529 dev-port band.)
## Core Thesis
Communication personalization is a signal processing problem. Every exchange between the system and a person produces observable signals — engagement, sentiment, timing, style — that decay over time and compound across conversations. tidalDB's signal ledger, preference vectors, windowed aggregation, and cohort system provide the learning substrate. iknowyou wraps these primitives with an observation pipeline (LM-as-classifier), a briefing engine (query-to-profile), and a generation interface (brief-to-prompt).

View File

@ -1,9 +1,5 @@
# iknowyou — Vision
> **Status — design vs shipped.** This vision describes the *target* tidalDB-native
> system. What runs today is a Next.js + vLLM implementation — see [ROADMAP.md](ROADMAP.md)
> and [devsetup.md](devsetup.md). Treat this as direction, not current-state documentation.
## The Problem
Every system that talks to people talks to all of them the same way.

View File

@ -1,73 +1,40 @@
# Multi-region tidalDB cluster image.
# LEGACY: This file was originally a simulated multi-region cluster image.
# The cluster mode has been removed from tidal-server. This Dockerfile now
# builds an identical standalone image and is preserved only to avoid breaking
# existing CI references.
#
# Runs a 3-region cluster (us-east, eu-west, ap-south) behind a single HTTP
# surface on port 9500. Regions replicate over the real tidal-net gRPC transport
# (loopback), but all run in this single container process — there is no host or
# process isolation, so it is NOT production HA (true multi-process deployment is
# m8p10). See docs/runbooks/cluster.md for the operational API.
#
# Build context is this repository's root (the workspace `Cargo.toml` /
# `Cargo.lock` and every member crate live there). Build from the repo root:
#
# docker build -f docker/cluster/Dockerfile -t tidaldb:cluster .
#
# The repo-root allowlist `.dockerignore` strips target/, node_modules/, .git,
# and .env* from the context, so `COPY . .` brings in exactly the workspace
# sources cargo needs to resolve the member graph and build `-p tidal-server`.
# Pin the builder to bookworm so its glibc matches the bookworm-slim runtime
# below. The default `rust:1.91` tracks Debian trixie (glibc 2.41), whose
# auto-vectorized math pulls in `libmvec.so.1` — a library bookworm's glibc does
# not ship, so a trixie-built binary aborts at startup on bookworm with
# "libmvec.so.1: cannot open shared object file".
FROM rust:1.91-bookworm AS builder
ARG DEBIAN_FRONTEND=noninteractive
# For new deployments use docker/standalone/Dockerfile instead.
FROM rust:1.91 as builder
WORKDIR /app
# Copy the full workspace and build only the server binary. The unified
# workspace requires every member manifest present to resolve metadata, so we
# copy the whole (already-pruned) context rather than a fragile manifest-only
# subset.
# Copy workspace manifests first for caching.
COPY Cargo.toml Cargo.lock ./
COPY tidal/Cargo.toml tidal/Cargo.toml
COPY tidalctl/Cargo.toml tidalctl/Cargo.toml
COPY tidal-server/Cargo.toml tidal-server/Cargo.toml
COPY applications/forage/engine/Cargo.toml applications/forage/engine/Cargo.toml
COPY applications/forage/server/Cargo.toml applications/forage/server/Cargo.toml
COPY applications/forage/embedder/Cargo.toml applications/forage/embedder/Cargo.toml
COPY applications/iknowyou/engine/Cargo.toml applications/iknowyou/engine/Cargo.toml
# Copy full workspace.
COPY . .
# g++ builds the usearch C++ HNSW core; protobuf-compiler (protoc) is needed by
# tidal-net's build script (tonic-build compiles the WAL-shipping .proto), which
# tidal-server now depends on for real gRPC cluster replication.
RUN apt-get update && apt-get install -y --no-install-recommends g++ protobuf-compiler && rm -rf /var/lib/apt/lists/*
RUN cargo build -p tidal-server --release --locked
RUN cargo build -p tidal-server --release
FROM debian:bookworm-slim
ARG DEBIAN_FRONTEND=noninteractive
WORKDIR /srv
RUN useradd --system --home /srv tidal && \
apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && \
apt-get update && apt-get install -y ca-certificates curl && \
rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/tidal-server /usr/local/bin/tidal-server
COPY --chown=tidal:tidal tidal-server/config /etc/tidal-server
COPY tidal-server/config /etc/tidal-server
USER tidal
EXPOSE 9500
# Path the server reads its schema/topology from. The binary-side clap wiring
# for this lives in tidal-server (see sibling task T5); keep the name and
# default in sync with it here.
ENV TIDAL_CONFIG=/etc/tidal-server
# Cluster mode is gated as experimental: replication is real gRPC but all
# regions share one process (no host/process isolation), so it refuses to start
# without an explicit opt-in. The image opts in here so `docker run` works; the
# server still logs a loud WARN that this is not production HA.
ENV TIDAL_ALLOW_EXPERIMENTAL_CLUSTER=1
EXPOSE 9400
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -f http://localhost:9500/health || exit 1
CMD curl -f -H "Authorization: Bearer ${TIDAL_API_KEY:-}" http://localhost:9400/health || exit 1
# ENTRYPOINT is just the binary so `docker run tidaldb:cluster <subcommand>`
# overrides work. CMD carries the default cluster invocation against the baked
# schema + topology.
ENTRYPOINT ["tidal-server"]
CMD ["cluster", "--listen", "0.0.0.0:9500", "--schema", "/etc/tidal-server/default-schema.yaml", "--topology", "/etc/tidal-server/default-cluster.yaml"]
ENTRYPOINT ["tidal-server", "standalone", "--listen", "0.0.0.0:9400"]

View File

@ -1,64 +0,0 @@
# Production single-node tidalDB deploy image (slim toolchain, durable /data).
#
# Build context is this repository's root (the workspace `Cargo.toml` /
# `Cargo.lock` and every member crate live there). Build from the repo root:
#
# docker build -f docker/deploy/Dockerfile -t tidaldb:deploy .
#
# The repo-root allowlist `.dockerignore` strips target/, node_modules/, .git,
# and .env* from the context, so `COPY . .` brings in exactly the workspace
# sources cargo needs to resolve the member graph and build `-p tidal-server`.
# Pin the builder to bookworm so its glibc matches the bookworm-slim runtime.
# The default slim tag tracks Debian trixie, whose vectorized-math
# `libmvec.so.1` is absent on bookworm and aborts the binary at startup.
FROM rust:1.91-slim-bookworm AS builder
ARG DEBIAN_FRONTEND=noninteractive
WORKDIR /build
# protobuf-compiler (protoc) is required by tidal-net's build script (tonic-build
# compiles the WAL-shipping .proto); tidal-server depends on tidal-net for gRPC
# cluster replication.
RUN apt-get update && apt-get install -y --no-install-recommends pkg-config libssl-dev g++ protobuf-compiler && rm -rf /var/lib/apt/lists/*
# Copy the full workspace and build only the server binary. The unified
# workspace requires every member manifest present to resolve metadata, so we
# copy the whole (already-pruned) context rather than a fragile manifest-only
# subset.
COPY . .
RUN cargo build -p tidal-server --release --locked
FROM debian:bookworm-slim
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
RUN useradd --system -u 10001 tidal
COPY --from=builder --chown=tidal:tidal /build/target/release/tidal-server /usr/local/bin/tidal-server
COPY --chown=tidal:tidal tidal-server/config /config
# Persistent data lives under /data, owned by the runtime user. Without this
# directory + the `--data-dir /data` flag below the server boots EPHEMERAL and
# loses every write on restart.
RUN mkdir -p /data && chown tidal:tidal /data
VOLUME ["/data"]
USER tidal:tidal
WORKDIR /data
EXPOSE 9400 9091
ENV TIDAL_SERVER_LOG=info
# Path the server reads its schema/topology from. The binary-side clap wiring
# for this lives in tidal-server (see sibling task T5); keep the name and
# default in sync with it here.
ENV TIDAL_CONFIG=/config
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -sf http://localhost:9400/health || exit 1
# ENTRYPOINT is just the binary so `docker run tidaldb:deploy <subcommand>`
# overrides work. CMD carries the durable standalone invocation against the
# baked schema, persisting to the /data volume.
ENTRYPOINT ["tidal-server"]
CMD ["standalone", \
"--listen", "0.0.0.0:9400", \
"--schema", "/config/default-schema.yaml", \
"--data-dir", "/data", \
"--metrics", "0.0.0.0:9091"]

View File

@ -10,10 +10,7 @@ services:
- TIDAL_API_KEY=${TIDAL_API_KEY}
- TIDAL_SERVER_LOG=info
volumes:
# Matches the standalone image's `VOLUME ["/data"]` + `--data-dir /data`.
# Mounting anywhere else leaves the data dir on the ephemeral container
# layer and loses every write on restart.
- tidaldb-data:/data
- tidaldb-data:/var/lib/tidaldb
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "-H", "Authorization: Bearer ${TIDAL_API_KEY}", "http://localhost:9400/health"]

View File

@ -1,54 +1,28 @@
# Single-node tidalDB server image.
#
# Build context is this repository's root (the workspace `Cargo.toml` /
# `Cargo.lock` and every member crate live there). Build from the repo root:
#
# docker build -f docker/standalone/Dockerfile -t tidaldb:standalone .
#
# tidal-server is a workspace member at `tidal-server/` and depends on the
# `tidal/` engine crate plus `tidal-net/` (gRPC cluster transport). The repo-root
# allowlist `.dockerignore` strips target/, node_modules/, .git, and .env* from
# the context, so `COPY . .` brings in exactly the workspace sources cargo needs
# to resolve the member graph and build `-p tidal-server`.
# Pin the builder to bookworm so its glibc matches the bookworm-slim runtime.
# The default `rust:1.91` tracks Debian trixie, whose vectorized-math
# `libmvec.so.1` is absent on bookworm and aborts the binary at startup.
FROM rust:1.91-bookworm AS builder
ARG DEBIAN_FRONTEND=noninteractive
FROM rust:1.91 AS builder
WORKDIR /app
# g++ builds the usearch C++ HNSW core; protobuf-compiler (protoc) is needed by
# tidal-net's build script (tonic-build compiles the WAL-shipping .proto).
RUN apt-get update && apt-get install -y --no-install-recommends g++ protobuf-compiler && rm -rf /var/lib/apt/lists/*
# Copy workspace manifests first for layer caching.
COPY Cargo.toml Cargo.lock ./
COPY tidal/Cargo.toml tidal/Cargo.toml
COPY tidalctl/Cargo.toml tidalctl/Cargo.toml
COPY tidal-server/Cargo.toml tidal-server/Cargo.toml
COPY applications/forage/engine/Cargo.toml applications/forage/engine/Cargo.toml
COPY applications/forage/server/Cargo.toml applications/forage/server/Cargo.toml
COPY applications/forage/embedder/Cargo.toml applications/forage/embedder/Cargo.toml
COPY applications/iknowyou/engine/Cargo.toml applications/iknowyou/engine/Cargo.toml
# Copy the full workspace and build only the server binary. The unified
# workspace requires every member manifest present to resolve metadata, so we
# copy the whole (already-pruned) context rather than a fragile manifest-only
# subset.
# Copy full workspace and build.
COPY . .
RUN cargo build -p tidal-server --release --locked
RUN cargo build -p tidal-server --release
FROM debian:bookworm-slim
ARG DEBIAN_FRONTEND=noninteractive
WORKDIR /srv
RUN useradd --system --home /srv tidal && \
apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && \
apt-get update && apt-get install -y ca-certificates curl && \
rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/tidal-server /usr/local/bin/tidal-server
COPY --chown=tidal:tidal tidal-server/config /etc/tidal-server
# Persistent data lives under /data, owned by the runtime user. Without this
# directory + the `--data-dir /data` flag below the server boots EPHEMERAL and
# loses every write on restart.
RUN mkdir -p /data && chown tidal:tidal /data
VOLUME ["/data"]
# Path the server reads its schema/topology from. The binary-side clap wiring
# for this lives in tidal-server (see sibling task T5); keep the name and
# default in sync with it here.
ENV TIDAL_CONFIG=/etc/tidal-server
COPY tidal-server/config /etc/tidal-server
USER tidal
EXPOSE 9400 9091
@ -56,11 +30,6 @@ EXPOSE 9400 9091
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -f -H "Authorization: Bearer ${TIDAL_API_KEY:-}" http://localhost:9400/health || exit 1
# ENTRYPOINT is just the binary so `docker run tidaldb:standalone <subcommand>`
# overrides work (e.g. `... cluster ...` or an ad-hoc inspect). CMD carries the
# default standalone invocation, including `--data-dir /data` for durability.
ENTRYPOINT ["tidal-server"]
CMD ["standalone", \
ENTRYPOINT ["tidal-server", "standalone", \
"--listen", "0.0.0.0:9400", \
"--data-dir", "/data", \
"--metrics", "0.0.0.0:9091"]

View File

@ -1,61 +0,0 @@
# tidalDB Engineering Docs
The engineering documentation home. Top-level product docs (VISION, USE_CASES, SEQUENCE,
ARCHITECTURE, API, QUICKSTART, CODING_GUIDELINES, thoughts) live at the
[repository root](../CLAUDE.md); everything below is the deeper engineering record.
> This and the repo root are the **two canonical doc homes**. There is intentionally no
> per-crate doc mirror (no `tidal/docs/`). Edit the canonical file, never a copy.
## Component specs — `specs/`
The authoritative component specifications (status: Implemented, M0M8).
| # | Spec | # | Spec |
|---|------|---|------|
| 00 | [Architecture overview](specs/00-architecture-overview.md) | 08 | [Query engine](specs/08-query-engine.md) |
| 01 | [Storage engine](specs/01-storage-engine.md) | 09 | [Ranking & scoring](specs/09-ranking-scoring.md) |
| 02 | [Entity model](specs/02-entity-model.md) | 10 | [Feedback loop](specs/10-feedback-loop.md) |
| 03 | [Signal system](specs/03-signal-system.md) | 11 | [Schema](specs/11-schema.md) |
| 04 | [Relationships](specs/04-relationships.md) | 12 | [Cold start](specs/12-cold-start.md) |
| 05 | [Cohorts](specs/05-cohorts.md) | 13 | [Concurrency](specs/13-concurrency.md) |
| 06 | [Text retrieval](specs/06-text-retrieval.md) | 14 | [Scale architecture](specs/14-scale-architecture.md) |
| 07 | [Vector retrieval](specs/07-vector-retrieval.md) | | |
## Planning — `planning/`
- [**ROADMAP.md**](planning/ROADMAP.md) — milestones M0M10, phase status, known gaps
- [PRODUCT_ROADMAP.md](planning/PRODUCT_ROADMAP.md) · [architecture-review.md](planning/architecture-review.md) · [roadmap-cohort-analysis.md](planning/roadmap-cohort-analysis.md) · [site-cohort-analysis.md](planning/site-cohort-analysis.md)
- Per-milestone phase/task archive: `planning/milestone-0,1,2,3,5,7,8,9,10,p/`
## Code reviews — `reviews/`
- [M0M10 code review — 2026-06-07](reviews/M0-M10-code-review-2026-06-07.md) — seven-dimension re-review, 88 verified findings
- [M0M10 code review — 2026-06-08](reviews/M0-M10-code-review-2026-06-08.md) — seven-dimension review, 142 findings (latest pass)
- [M0M10 seven-dimension review](reviews/M0-M10-seven-dimension-review.md) — additional pass (2 BLOCKERs: signal-checkpoint trim, 30-day window)
## Guides — `guides/`
Task-oriented, build-an-app docs (complements the root [QUICKSTART.md](../QUICKSTART.md) and [API.md](../API.md)):
- [**Build a feed app**](guides/build-a-feed-app.md) — end-to-end TikTok/Reels-style "For You" feed, embedded and over HTTP
- [**Embedding integration**](guides/embeddings.md) — wiring a real embedding model (OpenAI / Cohere / local) into the write + query paths
- [**Server deployment**](guides/server-deployment.md) — running the `tidal-server` HTTP service: config, auth, the served OpenAPI spec, Docker
- Ranking-profile reference: [ai-lookup/services/ranking-profiles.md](../ai-lookup/services/ranking-profiles.md) — all 25 built-in profiles
## Operations — `ops/` and `runbooks/`
- [Monitoring](ops/monitoring.md) · [Prometheus alerts](ops/prometheus-alerts.yaml) · [Grafana dashboard](ops/grafana-dashboard.json) · [Capacity planning](ops/capacity-planning.md) · [Recovery](ops/recovery.md)
- Runbooks: [Kubernetes](runbooks/kubernetes.md) · [Cluster (experimental)](runbooks/cluster.md)
## Research — `research/`
ANN ([1](research/ann_for_tidaldb.md), [2](research/ann_for_tidaldb_gemini.md)) · Tantivy ([1](research/tantivy.md), [2](research/tantivy_gemini.md)) · Signal ledger ([1](research/tidaldb_signal_ledger.md), [2](research/tidaldb_signal_ledger_gemini.md)) · [WAL](research/tidaldb_wal.md) · [Type system](research/phase1_1_type_system.md) · [Tooling & diagnostics](research/tidaldb_tooling_and_diagnostics.md) · [Enterprise-readiness risks](research/enterprise_readiness_risks.md)
## Profiling — `profiling/`
[Hotspot analysis](profiling/hotspot-analysis.md) · [Scale baselines](profiling/scale-baselines.md) · [Signal memory](profiling/signal-memory-analysis.md) · [Signal rollup eval](profiling/signal-rollup-eval.md) · [Social scale](profiling/social-scale.md) · [Tantivy merge tuning](profiling/tantivy-merge-tuning.md) · [USearch tuning](profiling/usearch-tuning.md)
## Strategy
[Content strategy](content-strategy.md) · [Personal-briefing beachhead](personal-briefing-beachhead.md)

View File

@ -1,620 +0,0 @@
# Build a Short-Video "For You" Feed on tidalDB
This is an end-to-end tutorial for building a TikTok/Reels-style short-video feed —
a swipeable home feed ("For You"), a Following feed, and a Discover/Trending tab —
on top of tidalDB. You will model the signals a short-video app actually emits,
ingest videos, wire the swipe feedback loop so a user's taste updates *immediately*,
and serve all three feed surfaces. You will do it twice: once embedded in a Rust
process, and once over HTTP with `curl`.
There is a verified, runnable version of the embedded path in this repo:
```bash
cargo run -p tidaldb --example foryou_feed
```
That example ([`tidal/examples/foryou_feed.rs`](../../tidal/examples/foryou_feed.rs))
is the source of truth for every Rust call shown below — this guide reproduces and
explains its key calls. Skim it after reading section 6.
**New to tidalDB?** Read [QUICKSTART.md](../../QUICKSTART.md) first for the 5-minute
schema → ingest → rank loop. This guide assumes you have run the quickstart and
understand schemas, signals, and the `RETRIEVE` query. The product framing for this
surface is [USE_CASES.md → UC-01 Personalized Feed (For You)](../../USE_CASES.md#uc-01--personalized-feed--for-you).
---
## 1. What tidalDB owns vs. what you build
tidalDB answers exactly one question: **given a user and a context, what content
should they see, in what order?** It owns *retrieval and ranking* — and the
feedback loop that learns taste from engagement. It owns nothing else.
Everything that produces, stores, or serves the actual video bytes is yours.
| Concern | Who owns it |
|---|---|
| Video upload, blob storage, object store | **You** |
| CDN, transcoding, HLS/DASH packaging, thumbnails | **You** |
| Embedding generation (run the video through a multimodal model) | **You** — see [docs/guides/embeddings.md](./embeddings.md) |
| Authentication, identity, sessions | **You** |
| Moderation, trust & safety, takedowns | **You** |
| Payments, creator payouts | **You** |
| Player UI, swipe gestures, autoplay | **You** |
| **Candidate retrieval (ANN + filters)** | **tidalDB** |
| **Signal ledgers: decay, velocity, windowed counts** | **tidalDB** |
| **Per-user preference (taste) vectors** | **tidalDB** |
| **Ranking profiles (`for_you`, `following`, `trending`, …)** | **tidalDB** |
| **Diversity enforcement + exploration** | **tidalDB** |
| **The swipe feedback loop** (engagement → updated taste, no Kafka lag) | **tidalDB** |
### Architecture sketch
```
┌──────────────┐ upload ┌─────────────────────────────────────┐
│ Creator app │────────────▶│ YOUR services │
└──────────────┘ │ • blob store + CDN + transcode │
│ • multimodal model → embedding │
│ • auth / identity / moderation │
└───────────────┬─────────────────────┘
write_item_with_metadata(id, meta)
write_item_embedding(id, vector) ← you bring the vector
┌──────────────┐ ┌───────────────────────────────┐
│ Viewer app │ GET feed │ tidalDB │
│ (player UI) │◀──── retrieve ────│ HNSW + signal ledgers + │
│ │ │ preference vectors + │
│ swipe ─────┼──── signals ─────▶│ ranking profiles + diversity │
│ (complete, │ signal_with_ │ │
│ like,skip) │ context(…) │ one process, one query API │
└──────────────┘ └───────────────────────────────┘
```
The viewer app does two things against tidalDB: it **reads** a ranked feed, and as
the user swipes it **writes** signals back. The write updates that user's taste in
the same process that serves the next read — there is no Kafka topic, no feature
store, and no batch job in between (section 4).
> tidalDB does **not** generate embeddings. Your model turns the video
> (frames + audio + caption) into a vector; tidalDB L2-normalizes it, inserts it
> into the HNSW index, and ranks over it. See [docs/guides/embeddings.md](./embeddings.md).
---
## 2. Model the signals for short video
A signal is a typed, timestamped event stream with native decay, velocity, and
windowed aggregation. You declare each signal once in the schema; the engine then
maintains its running decay score, per-window counts, and (optionally) velocity for
free. For short video, six signals capture the surface:
| Signal | Decay half-life | `positive_engagement` | Why |
|---|---|---|---|
| `view` | 7 days | no | The raw impression. Low-intent — a glance, not a preference. Drives trending velocity, not taste. |
| `completion` | 1 year (barely decays) | **yes** | Finishing a short video is the strongest, most durable taste signal there is. Weight = fraction watched ∈ [0,1]. |
| `like` | 30 days | **yes** | An explicit, durable positive. |
| `share` | 3 days (bursty) | no | Tracks `velocity` — shares spike, then fade; great for trending, not a private taste signal. |
| `skip` | 1 day | **no** | The swipe-away negative. Must **never** pull taste toward the skipped video, and marks it as a hard negative for that user. |
| `replay` | 14 days | **yes** | Re-watching is a meaningful, deliberate positive. |
### The `positive_engagement(true)` rule — read this twice
This is the single most important correctness rule for a personalized feed:
> **Preference-vector personalization fires only for signals declared
> `.positive_engagement(true)`.** When you record an engagement through
> `signal_with_context`, tidalDB folds the *item's content embedding* into the
> *acting user's* preference (taste) vector — but only if that signal type is a
> declared positive-engagement signal.
Why each classification matters:
- **`completion`, `like`, `replay` are `positive_engagement(true)`** → engaging
with a video nudges the user's taste vector toward that video's content. This is
the learning step. Get it right and the feed converges on what the user loves.
- **`view` is NOT positive-engagement** → a view is just "the video was shown."
If views folded into taste, a user's vector would be dragged toward everything
the feed happened to surface — a feedback bubble that learns nothing.
- **`skip` is NOT positive-engagement** → this is the load-bearing negative. A
swipe-away must never pull taste *toward* the rejected content. Routed through
`signal_with_context`, `skip` instead marks the video as a **hard negative** and
**seen** for that user, so the feed stops surfacing it.
If you accidentally mark `skip` or `view` as positive-engagement, the feed slowly
poisons every user's taste vector. Declare them honestly.
> **Subtle but important — the rule is all-or-nothing per database.** If *any*
> signal in your schema declares `positive_engagement(true)`, then **only** the
> signals you explicitly flagged fold the preference vector; nothing else does.
> If *no* signal declares it, tidalDB falls back to a built-in name allowlist
> (`like`, `share`, `completion`, `search_click`). The embedded schema below
> declares the flags explicitly, so it is fully in control. This matters for the
> HTTP path — see the caveat in section 9.
### Schema (embedded Rust)
This is lifted directly from
[`tidal/examples/foryou_feed.rs`](../../tidal/examples/foryou_feed.rs):
```rust
use std::time::Duration;
use tidaldb::schema::{DecaySpec, EntityKind, SchemaBuilder, Window};
let mut schema = SchemaBuilder::new();
// view: 7-day half-life, velocity + windows. Low-intent — NOT positive engagement.
let _ = schema
.signal("view", EntityKind::Item,
DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600) })
.windows(&[Window::OneHour, Window::TwentyFourHours, Window::AllTime])
.velocity(true)
.add();
// completion: ~1-year half-life. Weight = fraction watched. POSITIVE engagement.
let _ = schema
.signal("completion", EntityKind::Item,
DecaySpec::Exponential { half_life: Duration::from_secs(365 * 24 * 3600) })
.windows(&[Window::AllTime])
.positive_engagement(true)
.add();
// like: 30-day half-life. POSITIVE engagement.
let _ = schema
.signal("like", EntityKind::Item,
DecaySpec::Exponential { half_life: Duration::from_secs(30 * 24 * 3600) })
.windows(&[Window::AllTime])
.positive_engagement(true)
.add();
// share: 3-day half-life, bursty → velocity. NOT positive engagement.
let _ = schema
.signal("share", EntityKind::Item,
DecaySpec::Exponential { half_life: Duration::from_secs(3 * 24 * 3600) })
.windows(&[Window::TwentyFourHours, Window::AllTime])
.velocity(true)
.add();
// skip: fast 1-day decay — the swipe-away negative. NOT positive engagement.
let _ = schema
.signal("skip", EntityKind::Item,
DecaySpec::Exponential { half_life: Duration::from_secs(24 * 3600) })
.windows(&[Window::OneHour, Window::TwentyFourHours])
.add();
// replay: 14-day half-life. POSITIVE engagement.
let _ = schema
.signal("replay", EntityKind::Item,
DecaySpec::Exponential { half_life: Duration::from_secs(14 * 24 * 3600) })
.windows(&[Window::AllTime])
.positive_engagement(true)
.add();
// Content embedding slot for items: 128 dimensions (your model's output size).
let _ = schema.embedding_slot("content", EntityKind::Item, 128);
let schema = schema.build()?;
```
`DecaySpec` has three variants: `Exponential { half_life }`, `Linear { lifetime }`,
and `Permanent`. Available windows: `OneHour`, `TwentyFourHours`, `SevenDays`,
`ThirtyDays`, `AllTime`. Only declare the windows a profile actually reads —
each one is real bookkeeping.
---
## 3. Ingest videos (metadata + embeddings)
After your services have uploaded, transcoded, and embedded a video, write two
records into tidalDB: the metadata and the embedding vector.
```rust
use std::collections::HashMap;
use tidaldb::{TidalDb, schema::{EntityId, Timestamp}};
let db = TidalDb::builder().ephemeral().with_schema(schema).open()?;
// For durability: .with_data_dir("/var/lib/tidaldb") instead of .ephemeral()
let now = Timestamp::now();
let mut meta = HashMap::new();
meta.insert("title".to_string(), "Lo-fi piano loop".to_string());
meta.insert("category".to_string(), "music".to_string());
meta.insert("format".to_string(), "short".to_string());
meta.insert("creator_id".to_string(), "42".to_string());
meta.insert("duration".to_string(), "27".to_string()); // seconds
meta.insert("created_at".to_string(), now.as_nanos().to_string());
db.write_item_with_metadata(EntityId::new(1001), &meta)?;
// You bring the vector. This MUST be your model's output for THIS video.
let embedding: Vec<f32> = your_model.embed(&video); // length == slot dimensions (128)
db.write_item_embedding(EntityId::new(1001), &embedding)?;
```
Indexed metadata keys the engine understands: `title`, `category`, `format`,
`creator_id`, `tags`, `duration`, `created_at`. `creator_id` is what the
`max_per_creator` diversity cap (section 5) keys on, so always set it.
**Embedding rules (strict — read [docs/guides/embeddings.md](./embeddings.md) for
real vectors):**
- `write_item_embedding` L2-normalizes the vector and inserts it into the HNSW index.
- Dimensions are strict: **min 2, max 4096**, and the length **must equal the slot's
declared dimensions** (128 here) or the insert fails.
- **Zero-norm vectors are rejected.**
- **Multi-slot caveat:** `RETRIEVE`/`SEARCH` route through the **first declared slot
only**. A multi-modal app (separate text and image vectors) must fuse them offline
into one vector per slot, or model the modalities as separate entity kinds.
The example seeds 128-D *random* vectors purely so it runs standalone. A real app
must use genuine embeddings — random vectors give you random nearest-neighbors.
---
## 4. The swipe feedback loop (no Kafka, no feature-store lag)
This is what makes tidalDB a *feed* engine and not just a vector store. As the user
swipes, you record each engagement through `signal_with_context`, passing the user
id and the video's creator id. tidalDB writes the signal, updates windowed counts
and velocity, marks seen/hard-negative state, and — for positive-engagement signals —
folds the video's embedding into *that user's* preference vector. The next
`retrieve` call in the same process sees the updated taste. No queue, no batch job,
no feature store to keep in sync.
```rust
let user_id: u64 = 1001;
let creator_id: u64 = 42;
let video = EntityId::new(2001);
let now = Timestamp::now();
// User watched the whole thing → completion weight is the fraction watched (1.0).
// completion is positive-engagement → folds the video's embedding into the user's taste.
db.signal_with_context("completion", video, 1.0, now, Some(user_id), Some(creator_id))?;
// User tapped like → another positive fold, plus strengthens the (user, creator) edge.
db.signal_with_context("like", video, 1.0, now, Some(user_id), Some(creator_id))?;
// Different video — user swiped away after a glance. Partial view, then skip.
let rejected = EntityId::new(2002);
db.signal_with_context("view", rejected, 0.1, now, Some(user_id), Some(creator_id))?;
// skip is NOT positive-engagement: it does NOT pull taste toward `rejected`.
// It marks `rejected` as a hard negative + seen for this user, so the feed drops it.
db.signal_with_context("skip", rejected, 1.0, now, Some(user_id), Some(creator_id))?;
```
Contrast the two write APIs:
- `db.signal(type, id, weight, ts)`**global** signal, no user context. Use it for
aggregate counters that feed `trending` (e.g. a server-side view counter). It does
**not** touch any preference vector or seen-state.
- `db.signal_with_context(type, id, weight, ts, Some(user_id), Some(creator_id))`
**personalized**. Updates the user's preference vector (positive-engagement only),
seen-state, and hard negatives (skip/hide/block). This is the swipe-loop API.
A `completion` weight is the **fraction watched** in `[0.0, 1.0]` — 0.3 for a
three-second glance at a ten-second clip, 1.0 for a full watch (or a watch past the
end, if you clamp). The decay score blends these naturally: many partial completions
sum to less than a few full ones.
You can verify the loop landed by reading the live decay score:
```rust
let score = db.read_decay_score(EntityId::new(2001), "completion", 0)?; // window index 0
```
---
## 5. Serve the three feed surfaces
tidalDB ships **25 built-in ranking profiles**, registered automatically on
`open()`. A short-video app needs three of them. None require any extra schema — you
just name the profile in the query.
### For You — `profile("for_you")`
The personalized home feed. It blends interaction-weighted decay scores
(`view·1 + like·2 + share-velocity·1.5`) with the user's preference vector, sorts
with a Hot decay (`gravity = 1.5`), then enforces diversity and exploration. The
built-in defaults are:
- `max_per_creator = 2` — no single creator can dominate the feed.
- `format_mix_max_fraction = 0.4` — no one format swamps the mix.
- `exploration = 0.1`**10% of slots are exploration**: unseen/random items
injected to break the filter bubble and let *new creators surface*. Without this,
a feed converges to a narrow loop and new content never gets a chance.
Seen items and hard negatives (the videos this user already watched or skipped) are
filtered out automatically — a feed must surface fresh content.
```rust
use tidaldb::query::retrieve::Retrieve;
let for_you = Retrieve::builder()
.for_user(user_id) // personalization + seen/hard-negative filtering
.profile("for_you")
.limit(10)
.build()?;
let results = db.retrieve(&for_you)?;
for item in &results.items {
println!("#{} video={} score={:.4}", item.rank, item.entity_id.as_u64(), item.score);
}
```
Want to override the defaults for one query? Use `.diversity(...)`:
```rust
use tidaldb::ranking::diversity::DiversityConstraints;
let q = Retrieve::builder()
.for_user(user_id)
.profile("for_you")
.diversity(DiversityConstraints {
max_per_creator: Some(1), // stricter: one video per creator
format_mix_max_fraction: Some(0.5),
..DiversityConstraints::new()
})
.limit(20)
.build()?;
```
### Following — `profile("following")`
Content from creators the user follows, sourced from the relationship graph and
ranked by recency with a view-velocity boost (`max_per_creator = 3`). This requires
that you have recorded follow relationships (a follows edge) for the user — see
[USE_CASES.md](../../USE_CASES.md) for the relationship model.
```rust
let following = Retrieve::builder()
.for_user(user_id)
.profile("following")
.limit(20)
.build()?;
let results = db.retrieve(&following)?;
```
### Discover / Trending — `profile("trending")`
The global, *non-personalized* tab — what a brand-new visitor with no taste history
sees. It ranks every item by velocity (`view-velocity + 2·share-velocity` over the
24h window), capped at `max_per_creator = 1` for maximum variety. Note: velocity
needs signals arriving over real elapsed time to populate hour-level buckets, so a
freshly-seeded demo will show sparse trending scores — that is expected.
```rust
let trending = Retrieve::builder() // no .for_user — trending is global
.profile("trending")
.limit(20)
.build()?;
let results = db.retrieve(&trending)?;
```
> The payoff to look for in [`foryou_feed.rs`](../../tidal/examples/foryou_feed.rs):
> after a user completes + likes music videos and skips everything else, the top of
> their `for_you` feed is the *fresh* music videos — surfaced because the swipe
> session built a strong taste signal, **not** because of the (random) embeddings.
> The ordering is driven by signals, which is why it is stable across runs.
---
## 6. The whole thing, embedded (Rust)
The complete, verified program is at
[`tidal/examples/foryou_feed.rs`](../../tidal/examples/foryou_feed.rs). Run it:
```bash
cargo run -p tidaldb --example foryou_feed
```
It walks the full arc end to end:
1. Builds the six-signal short-video schema from section 2.
2. Opens an ephemeral DB and ingests ~24 videos across 5 creators.
3. Simulates one user's swipe session — completions + likes + a share + a replay on
music videos, skips on the rest — all via `signal_with_context`.
4. Retrieves `for_you` (diversity-capped, 10% exploration) and prints the ranked feed.
5. Retrieves global `trending` for contrast.
`Arc<TidalDb>` is `Send + Sync`, so the same handle backs every request thread in a
real server: clone the `Arc` into your axum/actix handlers, write signals on the
swipe endpoint, and read feeds on the feed endpoint. See the embedding-integration
examples (`axum_embedding`, `actix_embedding`, `cli_embedding`) in `tidal/examples/`
for the web-handler wiring.
A `Results` value contains `items: Vec<FeedItem>`; each `FeedItem` exposes `.rank`,
`.entity_id.as_u64()`, `.score`, and `.signals` (per-item signal snapshots for
explainability), plus `results.total_candidates` for the pre-diversity pool size.
For text + vector hybrid search (a search box rather than a feed), use the parallel
`Search::builder().query("...").vector(vec).for_user(uid).limit(n).build()?` then
`db.search(&q)?` — covered in [QUICKSTART.md](../../QUICKSTART.md) and
[API.md](../../API.md).
---
## 7. The whole thing, over HTTP
The same database behind a JSON HTTP API is `tidal-server`. Start a standalone
node with your schema:
```bash
cargo run -p tidal-server -- standalone \
--listen 0.0.0.0:9400 \
--schema ./short-video-schema.yaml \
--data-dir /var/lib/tidaldb \
--metrics 127.0.0.1:9091
```
Set `TIDAL_API_KEY` to require Bearer auth on the data routes. **If `TIDAL_API_KEY`
is unset, the server runs fully unauthenticated and logs a WARN** — never do that
outside a private dev box.
```bash
export TIDAL_API_KEY="$(openssl rand -hex 32)"
```
### Schema YAML
The HTTP server loads its schema from YAML. The short-video schema looks like:
```yaml
signals:
- name: view
entity: item
decay: { exponential: { half_life_seconds: 604800 } } # 7 days
windows: [one_hour, twenty_four_hours, all_time]
velocity: true
- name: completion
entity: item
decay: { exponential: { half_life_seconds: 31536000 } } # 1 year
windows: [all_time]
positive_engagement: true
- name: like
entity: item
decay: { exponential: { half_life_seconds: 2592000 } } # 30 days
windows: [all_time]
positive_engagement: true
- name: share
entity: item
decay: { exponential: { half_life_seconds: 259200 } } # 3 days
windows: [twenty_four_hours, all_time]
velocity: true
- name: skip
entity: item
decay: { exponential: { half_life_seconds: 86400 } } # 1 day
windows: [one_hour, twenty_four_hours]
- name: replay
entity: item
decay: { exponential: { half_life_seconds: 1209600 } } # 14 days
windows: [all_time]
positive_engagement: true
text_fields:
- { name: title, kind: text }
- { name: category, kind: keyword }
embedding_slots:
- { name: content, entity: item, dimensions: 128 }
```
> **`positive_engagement` works in YAML too.** Declaring it per signal (as
> above) gives the HTTP server the same explicit control as the embedded
> `SchemaBuilder``completion`, `like`, and `replay` fold into the user's
> preference vector; `view` and `skip` do not. If you omit the field on *every*
> signal, tidalDB falls back to a built-in name allowlist (`like`, `share`,
> `completion`, `search_click`); declaring it explicitly (recommended) removes
> any ambiguity about which signals shape taste.
The 25 built-in profiles (`for_you`, `following`, `trending`, …) are available on
the HTTP server with no `profiles:` block — they are registered automatically. Add a
`profiles:` block only to override a built-in or define your own.
### Routes
All routes are JSON. Data routes require `Authorization: Bearer $TIDAL_API_KEY` when
the key is set; `GET /health*` and `GET /openapi.json` are always unauthenticated.
```bash
# Ingest a video's metadata → 201 Created
curl -X POST http://localhost:9400/items \
-H "Authorization: Bearer $TIDAL_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "entity_id": 1001,
"metadata": { "title": "Lo-fi piano loop", "category": "music",
"format": "short", "creator_id": "42", "duration": "27" } }'
# Ingest its embedding (length MUST equal slot dimensions) → 204 No Content
curl -X POST http://localhost:9400/embeddings \
-H "Authorization: Bearer $TIDAL_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "entity_id": 1001, "values": [0.013, -0.041, /* … 128 floats … */ 0.092] }'
# The swipe loop: record engagement with user + creator context → 204 No Content
curl -X POST http://localhost:9400/signals \
-H "Authorization: Bearer $TIDAL_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "entity_id": 1001, "signal": "completion", "weight": 1.0,
"user_id": 1001, "creator_id": 42 }'
curl -X POST http://localhost:9400/signals \
-H "Authorization: Bearer $TIDAL_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "entity_id": 1001, "signal": "like", "weight": 1.0,
"user_id": 1001, "creator_id": 42 }'
# The swipe-away negative on a different video
curl -X POST http://localhost:9400/signals \
-H "Authorization: Bearer $TIDAL_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "entity_id": 1002, "signal": "skip", "weight": 1.0,
"user_id": 1001, "creator_id": 7 }'
# Serve the For You feed
curl -H "Authorization: Bearer $TIDAL_API_KEY" \
"http://localhost:9400/feed?user_id=1001&profile=for_you&limit=20"
# Following feed and the Discover/Trending tab
curl -H "Authorization: Bearer $TIDAL_API_KEY" \
"http://localhost:9400/feed?user_id=1001&profile=following&limit=20"
curl -H "Authorization: Bearer $TIDAL_API_KEY" \
"http://localhost:9400/feed?profile=trending&limit=20" # no user_id — global
```
When `?user_id=` and `?profile=` are omitted, `/feed` defaults to `profile=for_you`.
Passing `user_id` and `creator_id` on `/signals` is what activates the personalized
loop — omit them and the signal is recorded globally (the `signal` vs.
`signal_with_context` distinction from section 4).
Middleware on the data routes: 30s timeout (`408`), 100 max in-flight (`429`), 2 MB
body limit (`413`), and an `x-request-id` echoed on every response. The canonical,
always-current HTTP reference is the served spec at `GET /openapi.json` (unauthenticated).
---
## 8. Going to production — checklist
- [ ] **Durable storage.** Open with `.with_data_dir(...)` (embedded) or
`--data-dir` (server), not ephemeral. The WAL persists signals across restarts.
- [ ] **Real embeddings.** Replace the random vectors with your multimodal model's
output. Same model for ingest and query. See
[docs/guides/embeddings.md](./embeddings.md).
- [ ] **Auth on.** Set `TIDAL_API_KEY`. An unset key means an unauthenticated server
(it logs a WARN). Terminate TLS at your ingress.
- [ ] **Lock down metrics.** The `--metrics` Prometheus endpoint is **unauthenticated**
— bind it to loopback or a cluster-internal address only, never the public
interface.
- [ ] **Graceful shutdown.** On `SIGTERM` the server flips readiness to `503`,
drains in-flight requests, then checkpoints and fsyncs the WAL. Wire your
orchestrator's `preStop`/termination grace period to allow the drain.
- [ ] **Health probes.** Use `GET /health` for readiness (`200` ok / `503` draining),
`GET /health/startup` and `GET /health/live` for startup/liveness.
- [ ] **Observability.** Scrape the metrics endpoint; import the dashboard and alerts
from [docs/ops/](../ops/) (`grafana-dashboard.json`, `prometheus-alerts.yaml`,
`monitoring.md`). Plan headroom with [docs/ops/capacity-planning.md](../ops/capacity-planning.md).
- [ ] **Backup & recovery.** Rehearse restore with [docs/ops/recovery.md](../ops/recovery.md).
- [ ] **Deployment.** Follow [docs/guides/server-deployment.md](./server-deployment.md)
for packaging and rollout, and the Kubernetes runbook in
[docs/runbooks/](../runbooks/) for cluster orchestration. The container images
live at `docker/` (`standalone` / `cluster` / `deploy`) — build from the repo
root so `COPY . .` sees the whole workspace.
> **On cluster mode — be honest with yourself.** tidalDB's cluster mode is
> **experimental** and currently runs **all regions in a single process** over
> loopback gRPC. It is *not* production multi-process HA (true multi-process HA is
> tracked as m8p10). It also replicates **global signals only**`user_id` /
> `creator_id` contexts are rejected in cluster mode, so the personalized swipe loop
> (section 4) is a **single-node** feature today. Ship the For You feed on a
> vertically-scaled standalone node; reach for the experimental cluster only for
> read-scale experiments, per the [cluster runbook](../runbooks/cluster.md).
---
## See also
- [QUICKSTART.md](../../QUICKSTART.md) — the 5-minute ingest → rank loop.
- [USE_CASES.md → UC-01](../../USE_CASES.md#uc-01--personalized-feed--for-you) — product framing for the For You surface.
- [docs/guides/embeddings.md](./embeddings.md) — generating and writing real vectors.
- [docs/guides/server-deployment.md](./server-deployment.md) — packaging and rollout.
- [API.md](../../API.md) — full embedded + HTTP API reference.
- [`tidal/examples/foryou_feed.rs`](../../tidal/examples/foryou_feed.rs) — the verified, runnable version of this guide.

View File

@ -1,436 +0,0 @@
# Wiring a Real Embedding Model into tidalDB
tidalDB does **not** generate embeddings. It stores them, indexes them into HNSW,
and ranks over them. You bring the model; tidalDB owns retrieval and ranking. This
guide shows how to wire a production embedding model — OpenAI, Cohere, or a local
sentence-transformers / CLIP model — into the embedded engine and the HTTP server.
> If you only need a runnable end-to-end demo with random vectors first, run
> `cargo run -p tidaldb --example foryou_feed` and read [QUICKSTART.md](../../QUICKSTART.md).
> This guide is the next step: replacing those random vectors with a real model.
**See also:** [QUICKSTART.md](../../QUICKSTART.md) · [API.md — Writing Embeddings](../../API.md#writing-embeddings) · [API.md — `POST /embeddings`](../../API.md#post-embeddings) · [docs/guides/build-a-feed-app.md](build-a-feed-app.md) (companion: the full app around this).
---
## 1. The contract
tidalDB enforces a small, strict contract on every vector you write. Honoring it is
not optional — violations are rejected at the write boundary, not silently coerced.
| Rule | What tidalDB does | Failure mode |
|------|-------------------|--------------|
| **You generate, tidalDB stores** | `write_item_embedding` / `POST /embeddings` accept a `&[f32]` you computed. No model runs inside the database. | — |
| **L2 normalization is automatic** | The vector is L2-normalized on write so that L2 distance on the HNSW index equals cosine distance. You may pass an un-normalized vector; tidalDB normalizes it. | — |
| **Dimensions are strict: `[2, 4096]`** | A slot must declare dimensions in `[2, 4096]`. Out-of-range slot declarations fail at `schema.build()`. | `InvalidEmbeddingDimensions { slot, dimensions }` — "must be in [2, 4096]" |
| **Vector length must equal the slot** | Every inserted vector's `.len()` must equal the slot's declared dimensions exactly. | `DimensionMismatch { expected, got }` at insert |
| **Zero-norm vectors are rejected** | A vector whose squared sum is below `f32::EPSILON` (all zeros / underflow) has no direction and cannot do cosine similarity. | `ZeroNormVector` |
Three more facts that shape your integration:
- **Normalization is automatic, but you should still hand tidalDB a clean vector.**
Most embedding APIs already return unit-norm vectors (OpenAI and Cohere do).
If yours does not, tidalDB will normalize it — but a zero vector will be rejected
outright, so guard against degenerate output (empty input text, blank frames).
- **Multi-slot routing caveat.** A schema may declare several embedding slots, but
`RETRIEVE` and `SEARCH` route ANN through the **first declared Item slot only**
(resolved by `item_embedding_slot()`, defaulting to `"content"`). Multi-modal apps
must fuse modalities offline into one vector per item, or model each modality as a
separate entity kind. See [§7 Failure modes](#7-failure-modes).
- **Personalization needs `positive_engagement(true)`.** The per-user preference
vector that folds item embeddings into a user's taste is updated **only** for
signals declared `.positive_engagement(true)` and written via `signal_with_context`.
A `view` or `skip` that is not positive-engagement does not move the taste vector.
See [§4 The query side](#4-the-query-side).
---
## 2. Declare an embedding slot sized to your model
The slot's dimensions must equal your model's output dimensionality. Pick the slot
size **before** opening the database — it is baked into the on-disk schema fingerprint,
and changing it later is a new vector space (see [§6 Versioning](#6-model-versioning)).
### Embedded (Rust)
```rust
use tidaldb::schema::{SchemaBuilder, EntityKind};
let mut s = SchemaBuilder::new();
// ... declare your signals first (see QUICKSTART.md) ...
// One Item content slot, sized to text-embedding-3-small (1536D).
let _ = s.embedding_slot("content", EntityKind::Item, 1536);
let schema = s.build()?; // fails here if dimensions are outside [2, 4096]
```
Common model dimensions:
| Model | Native dims | Slot declaration |
|-------|-------------|------------------|
| OpenAI `text-embedding-3-small` | 1536 | `embedding_slot("content", EntityKind::Item, 1536)` |
| OpenAI `text-embedding-3-large` | 3072 (shortenable) | `embedding_slot("content", EntityKind::Item, 3072)` — or pass `dimensions: 1024` to the API and declare `1024` (see §3) |
| Cohere `embed-english-v3.0` / `embed-multilingual-v3.0` | 1024 | `embedding_slot("content", EntityKind::Item, 1024)` |
| `sentence-transformers/all-MiniLM-L6-v2` | 384 | `embedding_slot("content", EntityKind::Item, 384)` |
| `sentence-transformers/all-mpnet-base-v2` | 768 | `embedding_slot("content", EntityKind::Item, 768)` |
| OpenAI CLIP `ViT-B/32` (image/frame) | 512 | `embedding_slot("content", EntityKind::Item, 512)` |
> The 3072D of `text-embedding-3-large` is within the `[2, 4096]` ceiling, so it works
> directly. Larger models do not fit — if you need them, project down to ≤4096 (most
> have a documented `dimensions`/Matryoshka truncation parameter; re-normalize after).
### HTTP server (config YAML)
The server loads the same schema from YAML via `--schema`:
```yaml
embedding_slots:
- name: content
entity: item
dimensions: 1536 # must match your model and be in [2, 4096]
```
Start the server pointed at it:
```bash
tidal-server standalone \
--listen 0.0.0.0:9400 \
--schema ./schema.yaml \
--data-dir /var/lib/myapp/tidaldb \
--metrics 127.0.0.1:9091
```
> Set `TIDAL_API_KEY` to require `Authorization: Bearer <key>` on the data routes.
> If it is unset the server runs **unauthenticated** and logs a WARN. The `--metrics`
> endpoint is always unauthenticated — bind it to loopback / cluster-internal only.
---
## 3. Worked wiring per model
The shape is identical for every model: **your app calls its model, then hands the
vector to tidalDB.** Below, `db` is an `Arc<TidalDb>` opened against a schema whose
`content` slot matches the model's dimensions.
### OpenAI `text-embedding-3-small` (1536D)
```rust
use std::collections::HashMap;
use tidaldb::schema::{EntityId, Timestamp};
// Your model client. tidalDB never sees your API key or HTTP client.
async fn embed_openai(text: &str) -> anyhow::Result<Vec<f32>> {
let resp: serde_json::Value = reqwest::Client::new()
.post("https://api.openai.com/v1/embeddings")
.bearer_auth(std::env::var("OPENAI_API_KEY")?)
.json(&serde_json::json!({
"model": "text-embedding-3-small",
"input": text,
}))
.send().await?
.json().await?;
let v: Vec<f32> = resp["data"][0]["embedding"]
.as_array().ok_or_else(|| anyhow::anyhow!("no embedding"))?
.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect();
anyhow::ensure!(v.len() == 1536, "expected 1536D, got {}", v.len());
Ok(v)
}
// Ingest one item: metadata first, then its embedding.
async fn ingest(db: &tidaldb::TidalDb, id: u64, title: &str) -> anyhow::Result<()> {
let mut meta = HashMap::new();
meta.insert("title".into(), title.to_string());
meta.insert("created_at".into(), Timestamp::now().as_nanos().to_string());
db.write_item_with_metadata(EntityId::new(id), &meta)?;
let vec = embed_openai(title).await?; // app computes the vector
db.write_item_embedding(EntityId::new(id), &vec)?; // tidalDB stores + indexes it
Ok(())
}
```
curl, against the HTTP server:
```bash
# 1. Compute the vector with your model (here, OpenAI).
VEC=$(curl -s https://api.openai.com/v1/embeddings \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"text-embedding-3-small","input":"Introduction to Jazz Piano"}' \
| jq -c '.data[0].embedding')
# 2. Write metadata, then the embedding, to tidalDB.
curl -s -X POST http://localhost:9400/items \
-H "Authorization: Bearer $TIDAL_API_KEY" -H "Content-Type: application/json" \
-d '{"entity_id":1,"metadata":{"title":"Introduction to Jazz Piano"}}'
curl -s -X POST http://localhost:9400/embeddings \
-H "Authorization: Bearer $TIDAL_API_KEY" -H "Content-Type: application/json" \
-d "{\"entity_id\":1,\"values\":$VEC}" # 204 No Content on success
```
### OpenAI `text-embedding-3-large` (3072D, shortenable)
`text-embedding-3-large` is 3072D natively — within the `[2, 4096]` ceiling, so it
works directly with a `dimensions: 3072` slot. It is also **Matryoshka-shortenable**:
pass the `dimensions` parameter to trade a little accuracy for a smaller, faster index.
**The slot must match whatever you choose** — and once chosen, it is fixed for that
slot (see [§6](#6-model-versioning)).
```rust
async fn embed_openai_large(text: &str, dims: u32) -> anyhow::Result<Vec<f32>> {
let resp: serde_json::Value = reqwest::Client::new()
.post("https://api.openai.com/v1/embeddings")
.bearer_auth(std::env::var("OPENAI_API_KEY")?)
.json(&serde_json::json!({
"model": "text-embedding-3-large",
"input": text,
"dimensions": dims, // e.g. 1024 to shorten; omit for native 3072
}))
.send().await?.json().await?;
let v: Vec<f32> = resp["data"][0]["embedding"].as_array()
.ok_or_else(|| anyhow::anyhow!("no embedding"))?
.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect();
anyhow::ensure!(v.len() == dims as usize, "asked {dims}D, got {}", v.len());
Ok(v)
}
// Slot must be declared with the SAME dims you pass here:
// s.embedding_slot("content", EntityKind::Item, 1024); // if dims = 1024
// s.embedding_slot("content", EntityKind::Item, 3072); // if native
```
### Cohere `embed-english-v3.0` (1024D)
Cohere distinguishes `input_type` between indexing documents and embedding queries —
use `search_document` when ingesting items and `search_query` at query time. **Both
must be the same model.**
```rust
async fn embed_cohere(text: &str, input_type: &str) -> anyhow::Result<Vec<f32>> {
let resp: serde_json::Value = reqwest::Client::new()
.post("https://api.cohere.com/v1/embed")
.bearer_auth(std::env::var("COHERE_API_KEY")?)
.json(&serde_json::json!({
"model": "embed-english-v3.0",
"texts": [text],
"input_type": input_type, // "search_document" for items
"embedding_types": ["float"],
}))
.send().await?.json().await?;
let v: Vec<f32> = resp["embeddings"]["float"][0].as_array()
.ok_or_else(|| anyhow::anyhow!("no embedding"))?
.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect();
anyhow::ensure!(v.len() == 1024, "expected 1024D, got {}", v.len());
Ok(v)
}
// At ingest:
let vec = embed_cohere(title, "search_document").await?;
db.write_item_embedding(EntityId::new(id), &vec)?;
```
### Local model — sentence-transformers (text) or CLIP (video frames)
A local model is the same contract: produce a `Vec<f32>`, write it. Run the model in
your own service (Python, ONNX Runtime, Candle, etc.) and pass tidalDB the result.
**sentence-transformers (384D MiniLM):** your embedding service exposes, say,
`POST /embed -> {"vector": [...]}`. The tidalDB integration is just:
```rust
let vec: Vec<f32> = my_embed_service.embed(title).await?; // 384 floats
db.write_item_embedding(EntityId::new(id), &vec)?; // slot declared 384D
```
**CLIP for video frames (512D):** a short-video item has no single text to embed —
embed the content directly. tidalDB stores one vector per item, so fuse frames into
one vector before writing (sample N frames, CLIP each, mean-pool, then write):
```rust
// In your encoder service (pseudocode of the offline step):
// frames = sample_keyframes(video, n = 8)
// vecs = frames.map(clip_image_encode) // each 512D
// fused = mean_pool(vecs) // one 512D vector
// Hand the fused vector to tidalDB:
let fused: Vec<f32> = encoder.embed_video(&video_id).await?; // 512 floats, non-zero
db.write_item_embedding(EntityId::new(id), &fused)?; // slot declared 512D
```
> Guard against degenerate output: a blank frame or empty caption can yield an
> all-zero vector, which tidalDB rejects with `ZeroNormVector`. Skip or backfill those
> items rather than writing a zero.
curl is identical regardless of which model produced the vector — the server only sees
floats:
```bash
curl -s -X POST http://localhost:9400/embeddings \
-H "Authorization: Bearer $TIDAL_API_KEY" -H "Content-Type: application/json" \
-d '{"entity_id":7,"values":[0.013,-0.044, ... 512 floats ...]}'
```
---
## 4. The query side
**The query vector MUST come from the same model and version as the item vectors.**
A vector from a different model lives in a different geometric space; cosine similarity
across spaces is meaningless. There is no cross-space conversion — re-embed the query
text/image with the exact model you indexed with.
### Hybrid search — `Search::builder().vector(...)`
`SEARCH` fuses BM25 text relevance with ANN semantic similarity (Reciprocal Rank
Fusion). Provide both the query text and a query vector you computed:
```rust
use tidaldb::query::search::Search;
// SAME model as the items. For Cohere, use input_type = "search_query" here.
let qvec: Vec<f32> = embed_openai("relaxing jazz piano").await?; // 1536D, matches slot
let q = Search::builder()
.query("relaxing jazz piano") // BM25 side
.vector(qvec) // ANN side — same model/version as items
.for_user(42) // personalization (optional)
.limit(20)
.build()?;
let results = db.search(&q)?;
```
curl (hybrid): compute the query vector with your model, then pass it on the search.
The HTTP `GET /search` route takes `query`, `user_id`, and `limit`; for a query vector,
use the library API or the `POST`-bodied search if your deployment exposes one. Text-only
search is always available:
```bash
curl -s "http://localhost:9400/search?query=relaxing%20jazz%20piano&user_id=42&limit=20" \
-H "Authorization: Bearer $TIDAL_API_KEY"
```
### More like this — `.similar_to(item_id)`
To find items near an existing item, you do not need to embed anything — tidalDB uses
the stored vector of `item_id` as the query, through the first content slot:
```rust
let q = Search::builder()
.similar_to(EntityId::new(1)) // "more like item 1"
.for_user(42)
.exclude(vec![EntityId::new(1)]) // don't return the seed item
.limit(20)
.build()?;
let results = db.search(&q)?;
```
`Search::builder()` also takes `.diversity(DiversityConstraints { max_per_creator,
format_mix_max_fraction })` and `.exclude([ids])`. See
[API.md — SEARCH](../../API.md#search--text--semantic-retrieval).
> Pure feeds use `Retrieve` (`profile("for_you")`, etc.), which leans on signals and
> the per-user preference vector rather than an ad-hoc query vector. The preference
> vector is built from item embeddings folded in by `positive_engagement(true)` signals
> — so the query side of personalization is implicitly "the same model as your items."
---
## 5. Batching 1M items
The write path is one vector per call, and **re-writes are idempotent**
`write_item_embedding` overwrites the stored vector and re-inserts into the index, so
re-running a batch is safe (use it for resumable backfills). Batch the **model** calls
(that is where the latency and cost are), then write each result individually.
```rust
use tidaldb::schema::EntityId;
// Embedding APIs accept many inputs per request — batch the model, not the DB write.
for chunk in items.chunks(256) {
// One model call for up to 256 inputs (OpenAI/Cohere accept arrays).
let vectors: Vec<Vec<f32>> =
embed_batch_openai(chunk.iter().map(|i| i.title.as_str())).await?;
for (item, vec) in chunk.iter().zip(vectors) {
// Length is checked here against the slot — a bad chunk fails fast.
db.write_item_embedding(EntityId::new(item.id), &vec)?; // idempotent
}
}
db.flush_text_index()?; // make freshly written items searchable (ephemeral mode)
```
Operational notes for a 1M backfill:
- **Resumability.** Track the last successfully written id. On restart, re-run from
there — already-written vectors overwrite harmlessly. No dedup bookkeeping needed.
- **Index promotion is automatic.** A cold slot uses an exact brute-force index; once a
slot's durable vector count crosses the internal threshold, the next process restart
promotes it to HNSW (`UsearchIndex`). No config flag — just let the backfill complete
and restart.
- **Cost.** The model calls dominate cost and wall-clock. tidalDB's per-vector write is
cheap; size your batch to the model API's limits, not the database's.
- **HTTP path.** Over the server, the same shape: batch your model calls, then fire one
`POST /embeddings` per item (`{entity_id, values}` → 204). Mind the server's 2MB body
limit (413), 100 max in-flight (429), and 30s timeout (408) — a single 3072D vector is
well under 2MB, but do not pack many vectors into one body.
---
## 6. Model versioning
**A new embedding model is a new vector space. You cannot mix vectors from two models
in one slot** — their geometry is incomparable, and ANN results would be silently wrong
(no error, just nonsense neighbors). The slot's dimensions are part of the on-disk
schema fingerprint, so even a dimension change is a hard break: opening an existing
data directory with a different slot dimension fails schema validation.
When you upgrade or switch models, choose one of:
1. **Re-embed everything (clean cut).** Re-run the full backfill (§5) with the new
model, overwriting every item's vector in the same slot, then switch query-side
embedding to the new model atomically. Simplest; requires one full re-embed pass and
a brief window where new and old must not be queried interleaved. If dimensions
changed, you also need a fresh data directory (or a slot of the new size).
2. **Separate slot / entity kind (parallel spaces).** Declare a second embedding slot
(or model items as a distinct entity kind) for the new model, backfill it alongside
the old, and cut queries over once it is fully populated. This lets old and new
coexist during migration. **Caveat:** `RETRIEVE`/`SEARCH` ANN routes through the
**first** declared Item slot only — so the new slot only takes effect for default
queries once it is the first slot (i.e. on a schema that declares it first). Until
then, query the new space explicitly via your own search path. Plan the cutover
schema so the slot you want serving default queries is declared first.
Never write a v2 vector into a v1 slot "to save a re-embed." There is no compatibility
between spaces, and tidalDB cannot detect the mistake — the dimensions may even match.
---
## 7. Failure modes
| Symptom | Cause | Fix |
|---------|-------|-----|
| Insert error `DimensionMismatch { expected, got }` | Vector length ≠ slot dimensions. | Make the model output match the slot, or re-declare the slot. A truncated/padded vector is wrong even if it inserts — fix the model call. |
| `schema.build()` fails: "invalid dimensions … must be in [2, 4096]" | Slot declared outside `[2, 4096]`. | Pick a model ≤4096D, or project the vector down (Matryoshka `dimensions` param) and re-normalize before writing. |
| Insert error `ZeroNormVector` | All-zero / underflowing vector (blank input text, empty frame, model error returning zeros). | Guard the encoder; skip or backfill degenerate items. Never write a zero placeholder. |
| ANN returns nonsense neighbors, no error | Query vector from a different model/version than the items, **or** v2 vectors written into a v1 slot. | Re-embed the query with the exact item model (§4); never mix models in a slot (§6). |
| Multi-modal app: image search ignores the text slot (or vice-versa) | `RETRIEVE`/`SEARCH` route ANN through the **first** declared Item slot only. | Fuse modalities offline into one vector per item, or model each modality as a separate entity kind and query it explicitly. Plan slot order for the cutover. |
| `for_you` doesn't reflect a user's taste despite engagement | The engaged signal is not `positive_engagement(true)`, or you wrote `signal` instead of `signal_with_context`. | Declare taste-bearing signals (completion, like, replay) `.positive_engagement(true)` and write them via `signal_with_context(.., Some(user_id), ..)`. |
---
## What tidalDB owns vs. what you own
tidalDB owns **retrieval and ranking** over the vectors you give it: the HNSW index,
L2 normalization, signal-driven scoring, per-user preference vectors, diversity, and
the feed ordering — one process, one query.
You own **everything upstream**: embedding generation (running the model), the model's
API keys and clients, video/blob storage, CDN/transcoding, auth, moderation, and the
player UI. tidalDB never generates embeddings and never sees your model.
To build the full application around this — schema design, the signal loop, feeds, and
the HTTP surface — see [docs/guides/build-a-feed-app.md](build-a-feed-app.md), the
runnable [`foryou_feed` example](../../tidal/examples/foryou_feed.rs), and
[QUICKSTART.md](../../QUICKSTART.md).

View File

@ -1,364 +0,0 @@
# Server Deployment
Operational reference for running `tidal-server` — the HTTP wrapper around the embeddable `tidaldb` engine. This is the doc to read before exposing tidalDB over the network.
`tidal-server` is a thin Axum surface over the engine. It does not change the engine's correctness model: every write still goes through the WAL, every shutdown still checkpoints and fsyncs. What the server adds is an HTTP contract, Bearer auth, request middleware (timeouts, concurrency cap, body limit, request IDs), and a YAML-driven schema/profile loader so you can deploy without writing Rust.
> tidalDB owns **retrieval and ranking only**. It does **not** generate embeddings, store video/blobs, run a CDN/transcoder, or provide auth/moderation/payments. The server brings vectors in over `POST /embeddings`; your application produces them.
**Cross-references**
| Topic | Doc |
|-------|-----|
| HTTP + Rust API contract (handwritten) | [API.md](../../API.md) |
| Get-started walkthrough | [QUICKSTART.md](../../QUICKSTART.md) |
| Metrics, alert thresholds, Grafana | [docs/ops/monitoring.md](../ops/monitoring.md) |
| Crash recovery, WAL replay, backup/restore | [docs/ops/recovery.md](../ops/recovery.md) |
| Sizing memory/disk/CPU, scrape budgets | [docs/ops/capacity-planning.md](../ops/capacity-planning.md) |
| Kubernetes deployment (probes, volumes) | [docs/runbooks/kubernetes.md](../runbooks/kubernetes.md) |
| Cluster operation (experimental) | [docs/runbooks/cluster.md](../runbooks/cluster.md) |
---
## 1. Run Modes
`tidal-server` has two subcommands. Pick `standalone` unless you have a specific reason not to.
### Standalone — the recommended path
tidalDB is **single-node-first**. One process wraps one `TidalDb` instance, serves the full data + health surface, and scales vertically. This is the production-ready mode. It is the default in every shipped Docker image and the only mode covered by the durability and recovery guarantees in [docs/ops/recovery.md](../ops/recovery.md).
```bash
tidal-server standalone \
--listen 0.0.0.0:9400 \
--schema ./schema.yaml \
--data-dir /var/lib/tidaldb \
--metrics 127.0.0.1:9091
```
### Cluster — EXPERIMENTAL, not production HA
Cluster mode runs multiple "regions" behind one HTTP surface and replicates between them over the **real `tidal-net` gRPC transport**. The critical caveat: **all regions run inside this one process over loopback** — there is no host or process isolation, so a process crash takes down every region at once. **This is not production high availability.** True multi-process HA is tracked as **m8p10**.
The mode refuses to start unless you opt in explicitly (`--experimental-cluster` or `TIDAL_ALLOW_EXPERIMENTAL_CLUSTER=1`) and emits a loud `WARN` describing exactly what it is and is not. Cluster mode currently replicates **global signals only**`user_id`/`creator_id` signal context is dropped on the cluster path so followers stay consistent with the leader's WAL.
For everything cluster-specific (topology file, leader promotion, partition/heal, scatter-gather routes), see the **[cluster runbook](../runbooks/cluster.md)**. The rest of this guide focuses on standalone.
---
## 2. CLI Flags and Environment Variables
### Standalone flags
| Flag | Env override | Default | Meaning |
|------|--------------|---------|---------|
| `--listen <addr>` | `PORT` | `127.0.0.1:9400` | HTTP bind address. A bare `PORT` value (e.g. `PORT=8080`) is normalized to `0.0.0.0:8080`. A `host:port` string is used verbatim. |
| `--schema <path>` | — | compiled-in default | Path to the schema/profile YAML (see [section 3](#3-schema-yaml)). |
| `--config-dir <dir>` | `TIDAL_CONFIG` | unset | Directory holding `default-schema.yaml`. Used only when `--schema` is omitted; the explicit flag always wins. If the dir is set but the file is missing, startup **fails loudly** rather than silently serving compiled-in defaults. |
| `--data-dir <dir>` | — | **unset → EPHEMERAL** | Persistent data directory. **If omitted, the server runs in-memory and loses every write on restart.** Always set this in production. |
| `--metrics <addr>` | — | unset (disabled) | Bind address for the Prometheus endpoint. **Bind to loopback only — it is unauthenticated** (see [section 7](#7-metrics-and-shutdown)). |
### Cluster flags
| Flag | Env override | Default | Meaning |
|------|--------------|---------|---------|
| `--listen <addr>` | `PORT` | `127.0.0.1:9500` | HTTP bind address (same bare-port normalization). |
| `--schema <path>` | — | compiled-in default | Schema/profile YAML. |
| `--topology <path>` | — | compiled-in default | Cluster topology YAML (`regions`, `leader`). See the [cluster runbook](../runbooks/cluster.md). |
| `--config-dir <dir>` | `TIDAL_CONFIG` | unset | Directory holding `default-schema.yaml` and `default-cluster.yaml`. |
| `--experimental-cluster` | `TIDAL_ALLOW_EXPERIMENTAL_CLUSTER` | off | Required opt-in. Without it (flag or env `1`/`true`/`yes`), cluster mode refuses to start. |
### Environment variables (all modes)
| Variable | Default | Meaning |
|----------|---------|---------|
| `TIDAL_API_KEY` | unset | Bearer token for data routes. **If unset, the server is UNAUTHENTICATED** and logs a WARN at startup. See [section 4](#4-authentication). |
| `TIDAL_CONFIG` | unset | Config directory (backs `--config-dir`). |
| `PORT` | unset | Listen port/address (backs `--listen`); bare port → `0.0.0.0:PORT`. |
| `TIDAL_SERVER_LOG` | `info` | `tracing` env-filter directive, e.g. `TIDAL_SERVER_LOG=tidal_server=debug,info`. |
| `TIDAL_ALLOW_EXPERIMENTAL_CLUSTER` | unset | Truthy (`1`/`true`/`yes`) opts in to cluster mode. |
---
## 3. Schema YAML
The server loads its schema and ranking profiles from YAML at startup. This is the YAML projection of the Rust `SchemaBuilder` API. Four top-level keys: `signals` (required), `text_fields`, `embedding_slots`, `profiles` (all optional). At least one signal must be defined or startup fails.
### Top-level shape
```yaml
signals: # required, >= 1
text_fields: # optional
embedding_slots: # optional
profiles: # optional — built-in profiles are available regardless
```
### `signals`
```yaml
signals:
- name: view
entity: item # item | user | creator
decay:
exponential:
half_life_seconds: 604800 # exclusive: exponential | linear | permanent
windows: [one_hour, twenty_four_hours, seven_days]
velocity: true
positive_engagement: true # optional; folds into the user preference vector
```
- `decay` is exactly one of:
- `exponential: { half_life_seconds: <f64> }`
- `linear: { lifetime_seconds: <f64> }`
- `permanent: true`
- `windows` accepts: `one_hour` (`1h`), `twenty_four_hours` (`24h`), `seven_days` (`7d`), `thirty_days` (`30d`), `all_time` (`alltime`).
- `velocity: true` enables velocity tracking for the signal.
- `positive_engagement: true` makes engaging with an item via this signal fold
the item's embedding into the user's preference vector (personalization).
Omit it and the engine falls back to name-based detection — declare it
explicitly on signals like `view`/`like`/`completion` for predictable taste.
### `text_fields`
```yaml
text_fields:
- name: title
kind: text # text (analyzed, BM25) | keyword (exact)
- name: category
kind: keyword
```
### `embedding_slots`
```yaml
embedding_slots:
- name: content_vector
entity: item
dimensions: 128
```
Embedding rules to respect from the engine side:
- The app **brings vectors**; the server's `POST /embeddings` L2-normalizes them and inserts into the HNSW index. tidalDB does not generate embeddings.
- Dimensions are **strict**: min 2, max 4096, and the submitted vector length **must equal the slot's declared `dimensions`** or the insert fails. Zero-norm vectors are rejected.
- **Multi-slot caveat:** `RETRIEVE`/`SEARCH` route through the **first declared slot only**. Multi-modal apps must fuse offline or use separate entity kinds.
### `profiles`
Optional. The 25 built-in profiles (`for_you`, `trending`, `hot`, `new`, `following`, `related`, `top_week`, `most_viewed`, `controversial`, `hidden_gems`, `shuffle`, `cohort_trending`, etc.) are available without declaring anything. Use `profiles` to add custom ones or override behavior.
```yaml
profiles:
- name: for_you
version: 1
candidate_strategy: # default: scan(created_at)
ann:
slot: content_vector
limit: 200
sort: # string OR parameterized map
hot:
gravity: 1.5
boosts:
- { signal: like, agg: decay_score, window: all_time, weight: 2.5 }
- { signal: view, agg: velocity, window: one_hour, weight: 1.0 }
gates:
- { signal: skip, agg: value, window: all_time, min_threshold: 0.0 }
penalties:
- { signal: skip, agg: decay_score, window: seven_days, weight: 1.5 }
excludes:
- { signal: skip, agg: value, window: all_time, above: 1.0 }
diversity:
max_per_creator: 2
format_mix_max_fraction: 0.4
exploration: 0.1
```
Field reference:
- **`candidate_strategy`** — `scan` | `relationship` | `hybrid` | `cohort_trending`, or a map: `ann: { slot, limit }`, `signal_ranked: { signal, window }`, `scan: { sort_field }`. Default: `scan` over `created_at`.
- **`sort`** — string: `trending`, `rising`, `controversial`, `hidden_gems`, `shuffle`, `new`, `most_followed`, `creator_engagement_rate`, `alphabetical_asc`, `alphabetical_desc`, `shortest`, `longest`, `live_viewer_count`, `date_saved`; or map: `hot: { gravity }`, `top_window: { window }`, `most_viewed: { window }`, `most_liked: { window }`, `most_commented: { window }`, `most_shared: { window }`.
- **`boosts` / `penalties`** — `{ signal, agg, window, weight }`. `agg``value | velocity | decay_score | ratio | relative_velocity`.
- **`gates`** — `{ signal, agg, window, min_threshold }` (drop candidates below the threshold).
- **`excludes`** — `{ signal, agg, window, above }` (drop candidates above the threshold; e.g. hard-negative skip/hide/block).
- **`diversity`** — `{ max_per_creator, format_mix_max_fraction }`.
- **`exploration`** — `f64` in `[0,1]`.
Every `signal` named in a profile's boosts/gates/penalties/excludes/decay **must be declared in `signals`**, or startup fails with a clear error naming the offending profile and signal.
> **Correctness rule — preference-vector personalization.** The user "taste vector" that drives `for_you` is folded from item embeddings **only for signals declared `positive_engagement: true`** (the YAML field above; equivalently `SignalBuilder::positive_engagement(true)` in embedded mode). Declare it on your engaging signals (`view`/`like`/`completion`); if you omit it on *every* signal, the engine falls back to a name-based allowlist. If your personalization looks flat, confirm the engaging signals carry the flag. See [API.md](../../API.md) for the underlying `signal_with_context` semantics.
### Worked example (the shipped default)
```yaml
signals:
- name: view
entity: item
decay:
exponential:
half_life_seconds: 604800 # 7 days
windows: [one_hour, twenty_four_hours, seven_days]
velocity: true
- name: like
entity: item
decay:
exponential:
half_life_seconds: 1209600 # 14 days
windows: [twenty_four_hours, seven_days, thirty_days, all_time]
velocity: false
- name: skip
entity: item
decay:
permanent: true
velocity: false
text_fields:
- name: title
kind: text
- name: category
kind: keyword
embedding_slots:
- name: content_vector
entity: item
dimensions: 128
```
This is the compiled-in default at `tidal-server/config/default-schema.yaml`, used when neither `--schema` nor a `TIDAL_CONFIG` directory provides one.
---
## 4. Authentication
Data routes (`/items`, `/embeddings`, `/signals`, `/feed`, `/search`) require an `Authorization: Bearer <token>` header **when `TIDAL_API_KEY` is set**. The comparison is constant-time. A missing or wrong token returns `401` with `WWW-Authenticate: Bearer`.
Health probes (`/health`, `/health/startup`, `/health/live`) and the OpenAPI document (`/openapi.json`) are **always unauthenticated** — a client needs the spec to learn how to authenticate, and probes must work for orchestrators that cannot present a token.
> **WARNING — no key means an open server.** If `TIDAL_API_KEY` is unset (or empty), **every data route is served without authentication** and the server logs a startup WARN. This is fine for local development. **Never expose an unauthenticated server to any network.** Set `TIDAL_API_KEY` before binding to anything other than `127.0.0.1`.
```bash
export TIDAL_API_KEY="$(openssl rand -hex 32)"
curl -H "Authorization: Bearer $TIDAL_API_KEY" \
"http://localhost:9400/feed?user_id=42&profile=for_you&limit=20"
```
---
## 5. The Served OpenAPI Reference (`GET /openapi.json`)
`tidal-server` serves a machine-readable **OpenAPI 3.1** document at `GET /openapi.json`, unauthenticated. **This is the canonical HTTP API contract** — it is derived directly from the handler signatures and DTOs, so it can never drift from the running code (unlike a handwritten spec). [API.md](../../API.md) remains the prose companion, but `/openapi.json` is the source of truth for request/response shapes.
Inspect it:
```bash
curl -s localhost:9400/openapi.json | jq .info
# {
# "title": "tidalDB HTTP API",
# "version": "<crate version>",
# "description": "Embeddable, single-node-first database for the personalized content ranking problem..."
# }
```
You can load `/openapi.json` into any OpenAPI viewer (Swagger UI, Redoc, Stoplight, Postman) or feed it to a client generator (`openapi-generator`, `oapi-codegen`, etc.) to produce typed clients in any language. The standalone document describes the data + health surface; the cluster document is a superset that also describes the `/cluster/*` and `/sharded/*` routes. The `bearerAuth` security scheme is declared on the data routes so generated clients know to send the token; `/openapi.json` and the probes carry no security requirement.
### Route summary (standalone)
| Method + path | Auth | Success | Notes |
|---------------|------|---------|-------|
| `GET /health` | no | `200` ready / `503` draining | Readiness probe; reports item count. |
| `GET /health/startup` | no | `200` always | Startup probe. |
| `GET /health/live` | no | `200` always | Liveness probe. |
| `GET /openapi.json` | no | `200` | OpenAPI 3.1 document. |
| `POST /items` | yes | `201` | Body: `{ "entity_id": <u64>, "metadata": { ... } }`. |
| `POST /embeddings` | yes | `204` | Body: `{ "entity_id": <u64>, "values": [<f32>, ...] }`. |
| `POST /signals` | yes | `204` | Body: `{ "entity_id", "signal", "weight", "user_id"?, "creator_id"? }`. |
| `GET /feed` | yes | `200` | `?user_id=&profile=for_you&limit=20`. |
| `GET /search` | yes | `200` | `?query=&user_id=&limit=20`. |
`limit` is clamped to `1000` at the trust boundary. Request middleware: 30 s timeout (`408`), 100 max in-flight (`429`), 2 MB body cap (`413`), and an `x-request-id` echoed on every response.
---
## 6. Running the Server
### Via cargo (development)
```bash
# Ephemeral, loopback, default schema — quickest smoke test:
cargo run -p tidal-server -- standalone
# Durable, real schema, metrics on loopback:
cargo run -p tidal-server -- standalone \
--listen 0.0.0.0:9400 \
--schema tidal-server/config/default-schema.yaml \
--data-dir /var/lib/tidaldb \
--metrics 127.0.0.1:9091
```
For an end-to-end engine walkthrough (embedded, no server), run the verified example: `cargo run -p tidaldb --example foryou_feed`. See [QUICKSTART.md](../../QUICKSTART.md).
### Via Docker
Three images ship, all built from the **repository root** as build context:
| Image | Dockerfile | Toolchain | Use |
|-------|-----------|-----------|-----|
| standalone | `docker/standalone/Dockerfile` | `rust:1.91-bookworm` | General single-node; bundles `curl` for healthcheck. |
| deploy | `docker/deploy/Dockerfile` | `rust:1.91-slim-bookworm`, uid 10001 | Slim production single-node; bakes the schema, `WORKDIR /data`. |
| cluster | `docker/cluster/Dockerfile` | — | Experimental cluster mode; see the [cluster runbook](../runbooks/cluster.md). |
Both single-node images:
- Run as a **non-root** `tidal` user.
- Declare `VOLUME ["/data"]` and pass `--data-dir /data` in the default `CMD`. **Durability lives on `/data` — you must mount a real volume there or every write is lost on restart** (the container layer is ephemeral).
- Use `ENTRYPOINT ["tidal-server"]` + a `CMD` of subcommand args, so `docker run <image> <subcommand> ...` overrides work (e.g. running an inspect command).
- Set `ENV TIDAL_CONFIG` to the baked config dir (`/etc/tidal-server` for standalone, `/config` for deploy) so the container is self-contained.
- Expose `9400` (HTTP) and `9091` (metrics).
```bash
# Build (from repo root):
docker build -f docker/standalone/Dockerfile -t tidaldb:standalone .
# Run with a named volume for durability and an API key:
docker run -d --name tidaldb \
-p 9400:9400 \
-p 127.0.0.1:9091:9091 \
-e TIDAL_API_KEY="$(openssl rand -hex 32)" \
-v tidaldb-data:/data \
tidaldb:standalone
```
Note the metrics port is published to `127.0.0.1` only — it is unauthenticated (see [section 7](#7-metrics-and-shutdown)).
### Via docker-compose
`docker/docker-compose.yml` brings up the standalone image plus a Prometheus sidecar, with a named `tidaldb-data` volume mounted at `/data`:
```bash
cd docker
export TIDAL_API_KEY="$(openssl rand -hex 32)"
docker compose up -d
```
The compose healthcheck calls `GET /health` with the Bearer header. Adjust the `tidaldb-data` volume to a host bind mount if you want the data outside Docker's volume store. For Kubernetes equivalents (probes wired to `/health/startup`, `/health/live`, `/health`; a `PersistentVolumeClaim` at `/data`), see the [Kubernetes runbook](../runbooks/kubernetes.md).
---
## 7. Metrics and Shutdown
### Metrics
`--metrics <addr>` enables the engine's Prometheus endpoint on a **separate port** (default image: `9091`). It serves `/metrics` (Prometheus text format) and `/healthz` (JSON). **This endpoint is UNAUTHENTICATED — bind it to loopback or a cluster-internal interface only.** The engine logs a WARN if you bind it to a non-loopback address. For the full metric catalog, scrape config, alert thresholds, and Grafana dashboard, see [docs/ops/monitoring.md](../ops/monitoring.md). For sizing the scrape budget and memory/disk/CPU, see [docs/ops/capacity-planning.md](../ops/capacity-planning.md).
### Graceful shutdown
On `SIGTERM` (or Ctrl-C), the server:
1. **Flips readiness to `503`**`GET /health` reports `{ "ok": false, "cause": "shutting down" }` so a load balancer stops routing new traffic. Startup/liveness probes still return `200`.
2. **Drains** in-flight requests (axum graceful shutdown).
3. **Closes the database** — checkpoints state and **fsyncs the WAL** before the process exits. The standalone path reclaims sole ownership and runs this deterministically; if a flush fails it is logged at error level. There is a `Drop` backstop that runs the same shutdown if ownership is unexpectedly still shared.
Give orchestrators a `terminationGracePeriod` long enough to cover drain + checkpoint (a few seconds is typically ample for single-node; size against your WAL volume per [capacity planning](../ops/capacity-planning.md)). For crash recovery, WAL replay, and backup/restore, see [docs/ops/recovery.md](../ops/recovery.md).
### Reverse proxy and TLS termination
`tidal-server` speaks **plain HTTP** and does not terminate TLS. In production, front it with a reverse proxy (nginx, Caddy, Envoy, a cloud load balancer, or a Kubernetes Ingress) that:
- Terminates TLS and forwards to the server's `--listen` address.
- Performs health checks against `GET /health` (it returns `503` during drain, so the proxy stops sending traffic before the process exits).
- Optionally adds its own rate limiting in front of the server's 100-in-flight / 30 s-timeout middleware.
- Preserves or sets `x-request-id` if you want end-to-end tracing — the server generates one when absent and echoes it on the response.
Keep the metrics port off the public proxy entirely; scrape it over the internal network only.

View File

@ -137,7 +137,7 @@ Index health metrics are refreshed every 10 seconds by the checkpoint thread (3x
| WAL Disk Pressure | `tidaldb_wal_lag_bytes > 1000000000` | Warning | WAL exceeds 1 GB uncompacted. Compaction may be stuck or checkpoint is failing. |
| Signal Backlog | `tidaldb_signal_hot_entries > 4000000` | Warning | Signal ledger over 80% of the 5M entry budget. Cold entry trimming will begin at 5M. |
| Degraded Ranking | `tidaldb_degradation_level > 0` | Warning | Load-based degradation is active. Ranking quality is reduced to protect latency. Scale up or reduce load. |
| Session Leak | `deriv(tidaldb_active_sessions[5m]) > 0.03 AND tidaldb_active_sessions > 100` | Warning | Active session count trending up. `tidaldb_active_sessions` is a gauge, so `deriv()` (slope) is correct — `rate()`/`increase()` are invalid on gauges and never fire. Agents may not be closing sessions. |
| Session Leak | `rate(tidaldb_active_sessions[5m]) > 10 AND tidaldb_active_sessions > 100` | Warning | Active session count growing rapidly. Agents may not be closing sessions. |
| High Rate Limiting | `rate(tidaldb_rate_limited_total[5m]) > 100` | Info | Sustained rate limiting. Review agent rate limit configuration or reduce write volume. |
| Tantivy Segment Bloat | `tidaldb_tantivy_segment_count > 30` | Warning | Tantivy has many unmerged segments. Text syncer may be stalled. |

View File

@ -1,40 +1,3 @@
# =============================================================================
# DESIGN-REFERENCE RULE SET — NOT LOADED BY ANY ALERTMANAGER TODAY.
# =============================================================================
# This file is reference/design config. It is NOT wired into any running
# alerting pipeline:
# - vmalert (pilot/dev) loads ONLY ops/vmalert/rules/*.yaml
# - prod CRDs live ONLY in infra/k8s/prom/prometheus-rules/*.yaml
# Nothing mounts or imports docs/ops/prometheus-alerts.yaml.
# Do not read these as live pager rules.
#
# Provenance: every metric referenced below (tidaldb_health_ok,
# tidaldb_checkpoint_age_seconds, tidaldb_checkpoint_failures_total,
# tidaldb_wal_lag_bytes, tidaldb_signal_hot_entries, tidaldb_degradation_level,
# tidaldb_active_sessions, tidaldb_rate_limited_total,
# tidaldb_tantivy_segment_count, tidaldb_retrieve_latency_us_bucket,
# tidaldb_search_latency_us_bucket) IS really emitted today by
# tidal/src/db/metrics/mod.rs. So these rules are promotable — they
# are not orphaned against phantom metrics.
#
# Promotion path (the right long-term fix — do it deliberately, do NOT
# hand-copy these as live pager rules without the steps below):
# 1. Add a vmalert rule file: ops/vmalert/rules/tidaldb.yaml, translating
# each rule and stamping the canonical labels every routed rule carries —
# severity + capability + surface (+ optional component). Map per the
# canonical severity taxonomy in ops/alerting.md:
# labels {severity: critical} = pager class (TidalDBDown only here),
# {severity: warning} = non-paging / business-hours,
# {severity: info} = pure-FYI.
# Add capability: tidaldb and a surface label per rule, and a
# runbook_url annotation:
# https://github.com/orchard9/tidaldb/blob/main/docs/runbooks/<slug>.md
# 2. Add the prod CRD twin: infra/k8s/prom/prometheus-rules/tidaldb.yaml,
# kept byte-aligned in expr/threshold with the vmalert file.
# 3. Verify the alerts fire against real metrics (scrape tidaldb, force a
# degraded state) before declaring them on-call-ready.
# Until steps 13 land, this file stays a design reference only.
# =============================================================================
groups:
- name: tidaldb
interval: 30s
@ -87,16 +50,12 @@ groups:
description: "Degradation level {{ $value }} active. Scale up or reduce load."
- alert: TidalDBSessionLeak
# tidaldb_active_sessions is a GAUGE, so rate()/increase() are invalid
# (those operators only count counter resets). deriv() fits a least-squares
# slope over the window and is the gauge-correct way to detect sustained
# growth: > 0.03 sessions/sec is ~> 9 new sessions over a 5m window.
expr: deriv(tidaldb_active_sessions[5m]) > 0.03 and tidaldb_active_sessions > 100
expr: rate(tidaldb_active_sessions[5m]) > 10 and tidaldb_active_sessions > 100
for: 5m
labels: { severity: warning }
annotations:
summary: "Active session count growing steadily"
description: "{{ $value }} active sessions and trending up (deriv > 0.03/s). Agents may not be closing sessions."
summary: "Active session count growing rapidly"
description: "{{ $value }} active sessions and growing. Agents may not be closing sessions."
- alert: TidalDBHighRateLimiting
expr: rate(tidaldb_rate_limited_total[5m]) > 100

View File

@ -34,9 +34,9 @@ A single embeddable database can replace the 6-system content ranking stack by t
| M5 | Hybrid Search | Text + semantic + signal-ranked search in one query | UC-02, UC-10, UC-11 |
| M6 | Full Surface Coverage | Every use case, every sort mode, every filter, every feedback loop | UC-01 through UC-14 complete |
| M7 | Production Hardening | Crash safety, graceful degradation, operational readiness | All UCs at production quality |
| M8 | Distributed Fabric | Multi-region, multi-tenant replication keeps agent-memory semantics intact | Hosted tidalDB, cloud/edge deployments, shared agent substrate (in-process primitives + multi-node replication over real gRPC COMPLETE, G1 resolved; only tier-3 chaos tests — partition injection, clock-skew, rolling-upgrade — pending, m8p10) |
| M9 | Community Sync & Revocation | Local embeddable profiles can opt into community personalization and safely leave/purge contributions | Community personalization, federated taste graphs, shared feeds — ✅ COMPLETE (2026-06-06) |
| M10 | Governance & Agent Rights | Community rules and agent-scoped permissions control what signals influence ranking | User-owned AI personalization at scale, policy-compliant agents — ✅ COMPLETE (2026-06-06) |
| M8 | Distributed Fabric | Multi-region, multi-tenant replication keeps agent-memory semantics intact | Hosted tidalDB, cloud/edge deployments, shared agent substrate |
| M9 | Community Sync & Revocation | Local embeddable profiles can opt into community personalization and safely leave/purge contributions | Community personalization, federated taste graphs, shared feeds |
| M10 | Governance & Agent Rights | Community rules and agent-scoped permissions control what signals influence ranking | User-owned AI personalization at scale, policy-compliant agents |
### Embeddable → Distributed Path
@ -118,11 +118,7 @@ The roadmap now has two tracks:
| **m8p3: CRDT Counters and Deterministic Reconciliation** | COMPLETE | 1125 lib + 13 m8p3_crdt property tests; HLC/HlcTimestamp, PNCounter, LWWRegister, CrdtSignalState, ReconciliationEngine, StateSnapshot |
| **m8p4: Session Continuity Across Regions** | COMPLETE | 1163 lib + 8 m8p4_session integration tests; SessionSeqNo+HWM, IdempotencyKey/Store (lru 0.12), SessionReplicationBridge (sync crossbeam), HardNeg union semantics with HLC gating |
| **m8p5: Control Plane + Multi-Tenancy + Routing** | COMPLETE | 1194 lib + 5 m8p5_multitenancy integration tests; TenantId/TenantConfig, TenantRateLimiter (AtomicU64 CAS token-bucket), TenantRouter (Jump Consistent Hash), ControlPlane (shard heartbeat + health), TenantMigration state machine, RollingUpgradeCoordinator |
| **m8p6: End-to-End UAT (In-Process)** | COMPLETE | 1,206 lib + 8 m8_uat tests (0.11s); SimulatedCluster (signal-replay harness, 3 regions), NetworkPartition/ShardCrash RAII fault injection, 5 UAT steps + 3 perf assertions; p99 replication < 2s, failover < 10s, CRDT reconciliation < 100ms |
| **m8p7: Network Transport (gRPC)** | COMPLETE | `tidal-net` crate: `GrpcTransport` implementing `Transport` trait via tonic 0.12; `GrpcTransportFactory`; per-peer circuit breaker (Closed/Open/HalfOpen); mutual TLS via rustls; proto `WalShipping` service (ShipSegment, StreamSegments, Heartbeat); 16 tests (contract, mTLS, reconnection); benchmark harness |
| **m8p8: Multi-Node tidal-server** | COMPLETE | `cluster` subcommand with topology YAML; `ClusterState` wraps `SimulatedCluster` wired to **real `GrpcTransport`** per follower (self-loop over loopback gRPC; auto-allocated ports w/ bind-race retry); cluster HTTP routes; region-aware reads; leader-routed writes; blocking gRPC ships offloaded off the axum reactor; `cluster_grpc.rs` (2 in-process gRPC tests) + `cluster_e2e.rs` (tier-3 multi-process: smoke + promote); `docker/cluster/Dockerfile` opts in + runs. Single-process only (host/process isolation is m8p10) |
| **m8p9: Cross-Node Query Routing** | COMPLETE | `scatter_gather` module: entity-sharded writes via `hash(entity_id) % num_shards`; scatter-gather RETRIEVE/SEARCH across all shards; K-way merge by score; deadline propagation (50ms budget - 5ms overhead); partial failure with `degraded: true` + `unavailable_shards`; sharded HTTP routes (`/sharded/feed`, `/sharded/search`, `/sharded/items`, `/sharded/signals`) |
| **m8p10: Multi-Node UAT** | PARTIAL | Tier-2 (same-process, real gRPC): 8 tests in tidal-net/tests/multi_node_uat.rs — replication, idempotency, mixed signals, 3-node convergence, partition/heal, perf (100 signals in ~230ms). Tier-3 (multi-process harness with OS process isolation, iptables partition injection): NOT STARTED |
| **m8p6: End-to-End UAT** | COMPLETE | 1,206 lib + 8 m8_uat tests (0.11s); SimulatedCluster (signal-replay harness, 3 regions), NetworkPartition/ShardCrash RAII fault injection, 5 UAT steps + 3 perf assertions; p99 replication < 2s, failover < 10s, CRDT reconciliation < 100ms |
**M0 Embeddable Runtime: COMPLETE** — m0p1 (skeleton), m0p2 (tooling/diagnostics), m0p3 (samples/docs). Zero-config in-process runtime with WAL, fjall backend, and tidalctl CLI operational.
@ -140,15 +136,13 @@ The roadmap now has two tracks:
**M7 Production Hardening: COMPLETE** — m7p1m7p4 + Enterprise Readiness all done. Crash recovery (BLAKE3 integrity, WAL compaction), 4-stage graceful degradation, per-agent rate limiting, session TTL sweeper, scale to 1M items, Prometheus metrics, tidalctl diagnostics, RLHF export.
**M8 Distributed Fabric: NEAR-COMPLETE** — In-process primitives (m8p1p6) COMPLETE: shard routing, WAL shipping, CRDT reconciliation, session continuity, multi-tenancy, control plane. Multi-node (m8p7p9) COMPLETE: `tidal-net` crate with `GrpcTransport` (tonic, mTLS, circuit breaker); `tidal-server cluster` subcommand with HTTP routes + region-aware reads, now wired to **real `GrpcTransport`** for replication (m8p8 — `ClusterState` builds a self-loop gRPC transport per follower; the in-process crossbeam path is gone); scatter-gather query routing. m8p10 PARTIAL: tier-2 gRPC UAT (8 same-process tests) + an in-process `cluster_grpc.rs` HTTP-path test + a tier-3 multi-process `cluster_e2e.rs` (smoke + promote over OS processes, feature-gated) COMPLETE; remaining tier-3 work (iptables partition injection, clock-skew, rolling-upgrade) NOT STARTED. 1,209 lib + 22 tidal-net + 25 tidal-server tests passing.
**M8 Distributed Fabric: COMPLETE** — m8p1m8p6 all done. Shard-aware keyspaces, WAL shipping + follower replay, CRDT counters + deterministic reconciliation, session continuity across regions, control plane + multi-tenancy + jump-consistent routing, rolling upgrade coordinator. 1,206 lib + all phase integration tests passing.
**Forage: COMPLETE** — All 5 phases done (P0: demo loop, P1: real signal surface, P2: semantic embeddings, P3: adaptive MAB, P4: bridge/surprise moment). Chrome extension + forage-server + forage-engine + forage-embedder sidecar all operational.
**iknowyou / Aeries: IN PROGRESS (as of 2026-02-24)** — M1M4 complete. M5 (Communication Brief) is in progress with core implementation live; acceptance validation pending.
**Engine status:** M0M10 **COMPLETE**. M9 (Community Sync & Revocation) and M10 (Governance & Agent Rights) shipped 2026-06-06 — see *Implementation Status (M9M10 as-built)* below.
**Next (engine):** the cross-cutting community-overlay UAT surface — a single RETRIEVE that blends local + community layers and folds membership-epoch / policy-version / purge-watermark inline into `Results.policy_metadata` (the per-phase mechanisms and direct read APIs all exist; this is the unifying query surface). Then a multi-node M9/M10 UAT harness, and the deferred `writer_agent` u16 interning on the WAL v3 envelope.
**Next (engine):** M9 Phase 1 — Signal Scope and Share Contract.
**Next (product):** iknowyou M5 acceptance pass, then M6 Closed Loop (session lifecycle + preference drift validation).
---
@ -2658,246 +2652,13 @@ Then:
**Complexity:** M
**Task Files:** `docs/planning/milestone-8/phase-6/`
### ✅ M8 Phases 16 COMPLETE (In-Process Primitives)
### ✅ M8 COMPLETE
`cargo test --test m8_uat` passes all 5 UAT scenario steps using `SimulatedCluster` (multiple `TidalDb` instances in a single process connected via crossbeam channels). Signal replication, failover, partition/heal, CRDT reconciliation, and tenant migration all verified in-process. 1,206 lib tests + all M8 integration suites green.
**What this proves:** The distributed protocol logic is correct. WAL shipping, CRDT merge, idempotent replay, session continuity, tenant migration, and rolling upgrades all work when the transport is instantaneous and reliable.
**What this does NOT prove:** That the system works over a real network with real latency, real packet loss, real clock skew, real process boundaries, and real failure modes. The gap between in-process simulation and production multi-node is where distributed systems actually break.
### M8 Phases 710: Multi-Node Implementation (COMPLETE)
These phases take the proven in-process primitives and deliver actual multi-node operation. The architecture follows `docs/specs/14-scale-architecture.md` Section 10 (The Single-Node to Distributed Path).
#### Phase 7: Network Transport (m8p7)
**Delivers:** A gRPC-based `Transport` implementation using tonic, connection management with reconnection and backpressure, mutual TLS, and benchmark parity with `InProcessTransport`.
**Crate boundary:** Network code MUST NOT enter the `tidal` core crate. The core remains embeddable with zero network dependencies. Two options:
- **Option A (recommended):** New `tidal-net` crate depending on `tidal` + `tonic`. Contains `GrpcTransport`, connection pool, TLS config.
- **Option B:** Module within `tidal-server` (simpler if only the server uses it).
**Implementation specifics:**
1. **Proto definition** — Define `WalShipping` gRPC service with:
- `ShipSegment(WalSegmentRequest) returns (ShipSegmentResponse)` — unary, used by `WalShipper`
- `StreamSegments(StreamRequest) returns (stream WalSegmentPayload)` — server-streaming, used by `SegmentReceiver`
- `Heartbeat(HeartbeatRequest) returns (HeartbeatResponse)` — health check for `ControlPlane`
2. **`GrpcTransport` implementing `Transport` trait** — The trait is at `tidal/src/replication/transport.rs`. `send_segment` maps to `ShipSegment` RPC. `recv_segment` maps to consuming the `StreamSegments` stream. `local_shard` returns the configured `ShardId`.
3. **Connection management:**
- Connection pool: one persistent gRPC channel per peer shard (tonic `Channel` with `connect_lazy`)
- Reconnection: exponential backoff (100ms → 200ms → 400ms → ... → 30s cap)
- Circuit breaker: after 5 consecutive failures, mark peer as unreachable for 30s; `ControlPlane` reflects this as `RegionHealth::Degraded`
- Backpressure: bounded channel (1024 segments) between shipper and gRPC send loop; if full, log warning and drop oldest (WAL segments are durable on leader, follower will catch up)
4. **Mutual TLS:**
- `tonic` native TLS via `rustls` (pure Rust, no OpenSSL dependency)
- CA certificate, server cert, client cert configurable via `NodeConfig` fields
- Plaintext mode for development (`--insecure` flag)
5. **Testing:**
- Run the full `SimulatedCluster` test suite substituting `GrpcTransport` on localhost for `InProcessTransport`. Behavior must be identical.
- Benchmark: WAL shipping throughput (segments/sec) and latency (p50/p99) vs `InProcessTransport`. Target: <5% overhead on localhost.
**Acceptance Criteria:**
- [ ] `GrpcTransport` implements `Transport` trait; compiles and passes trait contract tests
- [ ] All `SimulatedCluster` tests pass with `GrpcTransport` on localhost (crossbeam channels replaced by gRPC)
- [ ] Connection pool reconnects after peer restart within 5s
- [ ] Mutual TLS works; plaintext rejected unless `--insecure` is set
- [ ] Benchmark: localhost WAL shipping within 5% of `InProcessTransport` throughput
- [ ] No `tonic`, `prost`, or network dependencies added to the `tidal` crate
**Depends On:** Phase 6 (proven in-process correctness)
**Complexity:** L
**Research Reference:** `docs/specs/14-scale-architecture.md` Section 9 (Replication Strategy)
#### Phase 8: Multi-Node tidal-server (m8p8)
**Delivers:** The `cluster` subcommand for `tidal-server`, a `ClusterState` analogous to the existing `ServerState`, cluster HTTP routes matching the runbook (`docs/runbooks/cluster.md`), leader write forwarding, and region-aware read routing.
**Implementation specifics:**
1. **`cluster` subcommand** — CLI: `tidal-server cluster --listen 0.0.0.0:9500 --schema <path> --topology <path>`. The topology YAML defines regions, shards, and peer addresses:
```yaml
regions:
- name: us-east
role: leader
shards: [{ id: 0, listen: "10.0.1.1:9600" }]
- name: eu-west
role: follower
shards: [{ id: 1, listen: "10.0.2.1:9600" }]
- name: ap-south
role: follower
shards: [{ id: 2, listen: "10.0.3.1:9600" }]
```
2. **`ClusterState`** — Multi-node equivalent of `ServerState`. Wraps:
- A local `TidalDb` instance (this node's shard)
- `GrpcTransport` connections to all peers
- `ControlPlane` for health tracking
- `WalShipperHandle` (leader) or `SegmentReceiverHandle` (follower)
- Topology config for routing decisions
3. **Cluster HTTP routes** (matching the runbook):
- `GET /health` — local node health
- `GET /cluster/status` — aggregated cluster status via `ControlPlane::health()`
- `POST /cluster/promote` — promote a follower to leader (calls `promote_leader` and reconfigures WAL shipping direction)
- `POST /cluster/partition` / `POST /cluster/heal` — for testing only; simulates partition by pausing WAL shipping to a region
4. **Leader forwarding:**
- If a follower receives a write request (`POST /items`, `/embeddings`, `/signals`), it forwards to the leader via gRPC and returns the leader's response
- The leader applies the write and ships WAL to followers (existing path)
- Response includes `X-TidalDB-Leader: us-east` header for client-side optimization
5. **Region-aware reads:**
- `?region=eu-west` query parameter on `/feed` and `/search` routes reads from the specified region's local `TidalDb`
- Without `?region`, reads from any healthy node (prefer local)
6. **Un-mark LEGACY Dockerfile:**
- Update `docker/cluster/Dockerfile` to build a real cluster image
- Entrypoint: `tidal-server cluster --listen 0.0.0.0:9500`
**Acceptance Criteria:**
- [x] `tidal-server cluster` starts with 3-region topology YAML; all nodes connect (real gRPC) and report healthy
- [x] `GET /cluster/status` returns leader identity, per-region applied events, lag, and partition status
- [x] `POST /signals` returns success; signal appears on all followers within 5s **over real gRPC** (`cluster_grpc.rs`, `cluster_e2e.rs` smoke)
- [x] `POST /cluster/promote` changes leader; subsequent writes route to new leader (`cluster_e2e.rs` promote)
- [x] `?region=eu-west` on `/feed` returns results from the eu-west follower
- [x] `docker/cluster/Dockerfile` opts in (`TIDAL_ALLOW_EXPERIMENTAL_CLUSTER=1`) and runs a functional 3-region cluster
> **Resolved (m8p8):** `ClusterState` now wires each follower to a real
> `tidal-net` `GrpcTransport` (self-loop over loopback gRPC) instead of crossbeam
> channels — closing gap G1. Blocking gRPC ships are offloaded off the axum
> reactor; auto-allocated gRPC ports self-heal a transient bind race. All regions
> still share one process: true multi-process/host isolation (process-crash,
> partition-injection, rolling-upgrade) remains tier-3 / m8p10.
**Depends On:** Phase 7 (network transport)
**Complexity:** XL
**Research Reference:** `docs/runbooks/cluster.md`, `docs/specs/14-scale-architecture.md` Section 10.3
#### Phase 9: Cross-Node Query Routing (m8p9)
**Delivers:** Scatter-gather query execution for multi-shard deployments where entities are hash-partitioned across data shards. Deadline propagation, partial failure handling, and result merging with degradation flag.
**Implementation specifics:**
1. **When this matters:** Phase 8 deploys full replicas (each node has all data). Phase 9 enables the partitioned architecture from spec Section 4.5 (Option C: Entity-Sharded with Replicated Global State) where entities are split across data shards and queries must fan out.
2. **Scatter-gather RETRIEVE:**
- Query router determines which shards hold candidate entities (after ANN retrieval on local HNSW replica)
- Batched parallel gRPC calls to data shards for signal enrichment (spec Section 7.3: ~50 entities per shard, ~525us per shard)
- K-way merge of scored results preserving ranking order
- Diversity enforcement applied after merge (on the coordinator)
3. **Scatter-gather SEARCH:**
- If Tantivy index is replicated to all query nodes: local search, no scatter-gather needed
- If Tantivy index is partitioned: fan out to shards, merge by RRF score, re-rank top-K
4. **Deadline propagation:**
- Query has a 50ms total budget (configurable)
- Coordinator subtracts estimated network overhead (5ms default) and passes remaining deadline to shards
- Shard-local execution respects the deadline; returns partial results if time runs out
- gRPC deadline metadata propagated via `tonic::Request::set_timeout()`
5. **Partial failure handling:**
- If one shard is unreachable, return results from available shards
- Response includes `degraded: true` and `unavailable_shards: ["s2"]` metadata
- Never fail the entire query because one shard is down
6. **Signal enrichment cache (Phase 3+ optimization from spec Section 7.3):**
- Query nodes maintain an LRU cache of recently-accessed entity signal states
- Cache populated by piggybacking on WAL replication stream
- Expected hit rate for personalized feeds: 60-80% (popular items repeat across users)
- Defer to Phase 10 or later; start with batched parallel reads (Approach 1)
**Acceptance Criteria:**
- [ ] RETRIEVE query across 4 data shards returns correct top-50 with diversity enforcement
- [ ] Query completes within 50ms budget on local network (same-rack)
- [ ] One unreachable shard returns partial results with `degraded: true`; no error
- [ ] Deadline propagation: shard receives timeout = query_deadline - network_overhead
- [ ] Signal enrichment: batched parallel reads to data shards, ~500us per shard
**Depends On:** Phase 8 (multi-node server)
**Complexity:** XL
**Research Reference:** `docs/specs/14-scale-architecture.md` Sections 7 (Query Routing) and 7.3 (Signal Enrichment)
#### Phase 10: Multi-Node UAT (m8p10)
**Delivers:** Real multi-process cluster tests that verify the full M8 UAT scenario over actual network connections, with real failure injection, real clock skew, and real process boundaries.
**Implementation specifics:**
1. **Multi-process test harness:**
- Spawn 3 `tidal-server cluster` processes (one per region) on different ports
- Automated setup: generate topology YAML, start processes, wait for healthy, run tests, tear down
- Integration with `cargo test` via a `#[cfg(feature = "cluster-tests")]` feature flag (disabled by default; these tests are slow)
2. **Network partition injection:**
- Use `iptables` rules (Linux) or `pfctl` (macOS) to drop traffic between specific nodes
- Alternative: proxy-based partition using `toxiproxy` or similar
- Test: partition eu-west from ap-south, write signals, heal, verify CRDT convergence
3. **Clock skew simulation:**
- Inject clock offset via HLC's `max(wall_clock, last_seen + 1)` mechanism
- Verify that HLC timestamps remain causally consistent even with 500ms wall-clock skew between nodes
4. **Rolling upgrade test:**
- Start 3-node cluster on version N
- Upgrade one node at a time to version N+1 (simulate by restarting with different config)
- Verify no data loss, no replication stall, no WAL format incompatibility during mixed-version window
5. **Cluster runbook verification:**
- Execute every operation in `docs/runbooks/cluster.md` against the real multi-process cluster
- Verify each response matches the documented sample output format
6. **Performance assertions (from M8 UAT scenario):**
- Cross-region replication lag < 2s p99 (over localhost gRPC)
- Failover via `/cluster/promote` < 10s
- CRDT reconciliation after partition heal < 100ms
- No signal loss or duplication after any failure scenario
**Acceptance Criteria:**
- [ ] 3-process cluster starts, seeds data, and passes all 5 original M8 UAT scenario steps over gRPC
- [ ] Network partition between two followers: writes continue on leader; heal restores convergence with no data loss
- [ ] Rolling upgrade: mixed-version window produces no errors or data corruption
- [ ] Every cluster runbook operation works against the real cluster
- [ ] Performance: replication < 2s, failover < 10s, reconciliation < 100ms (all over localhost)
**Depends On:** Phases 7, 8, 9
**Complexity:** XL
**Task Files:** `docs/planning/milestone-8/phase-10/` (to be created)
### M8 Known Gaps (Verified 2026-04-11)
| Gap | Phase | Severity | Description |
|-----|-------|----------|-------------|
| ~~G1~~ | m8p8 | ~~Medium~~ RESOLVED | `ClusterState` now wires each follower to a real `tidal-net` `GrpcTransport` (self-loop over loopback gRPC), replacing the crossbeam path. Replication between regions traverses real gRPC/TCP. Verified by `cluster_grpc.rs` (in-process) and `cluster_e2e.rs` (multi-process). |
| G2 | m8p10 | Medium | Tier-3 multi-process harness exists (`cluster_e2e.rs`: smoke + promote over OS processes), but iptables/pfctl partition injection, clock-skew, and rolling-upgrade scenarios are still NOT STARTED |
| G3 | m8p9 | Low | `entity_shard()` uses Knuth multiplicative hash while `TenantRouter` uses Jump Consistent Hash for the same entity→shard routing problem; must unify before entity-sharded production |
### Done When (M8 Full)
A developer can:
1. Start a 3-node tidalDB cluster from a topology YAML config
2. Write signals to any node (leader forwarding)
3. Read from any node with region routing
4. Partition a node, write during partition, heal, and verify CRDT convergence with no data loss
5. Promote a new leader with zero signal loss
6. All operations work over real gRPC network connections, not just in-process channels
The cluster runbook (`docs/runbooks/cluster.md`) is fully operational against real multi-process deployments.
`cargo test --test m8_uat` passes all 5 UAT scenario steps. Signal replication, failover, partition/heal, CRDT reconciliation, and tenant migration all verified. 1199 lib tests + all M8 integration suites green. Embeddable users have the full distributed fabric primitive set available without any API changes to signal/retrieve paths.
---
## Milestone 9: Community Sync & Revocation -- "Join, share, leave, purge" — ✅ COMPLETE (2026-06-06)
## Milestone 9: Community Sync & Revocation -- "Join, share, leave, purge"
### Milestone Thesis
@ -2935,10 +2696,10 @@ Then:
**Acceptance Criteria:**
- [x] WAL event envelope carries `scope`, `origin`, `share_policy_version`, and idempotency key.
- [x] Default behavior is local-only; sharing is explicit opt-in.
- [x] Per-intent share filters supported (`skip_for_now`, `not_for_me`, `low_quality`, `hide/mute/block`, etc.).
- [x] `tidalctl` can inspect scope distribution and share eligibility.
- [ ] WAL event envelope carries `scope`, `origin`, `share_policy_version`, and idempotency key.
- [ ] Default behavior is local-only; sharing is explicit opt-in.
- [ ] Per-intent share filters supported (`skip_for_now`, `not_for_me`, `low_quality`, `hide/mute/block`, etc.).
- [ ] `tidalctl` can inspect scope distribution and share eligibility.
**Depends On:** M8
**Complexity:** L
@ -2949,10 +2710,10 @@ Then:
**Acceptance Criteria:**
- [x] Membership states: `joined`, `leaving_stop_forward`, `left`, `rejoined`.
- [x] `leave(stop_forward)` blocks new community contributions in < 1s p99.
- [x] Rejoin creates a new membership epoch (no ambiguous replay across epochs).
- [x] Queries expose active membership epoch for debugging and explainability.
- [ ] Membership states: `joined`, `leaving_stop_forward`, `left`, `rejoined`.
- [ ] `leave(stop_forward)` blocks new community contributions in < 1s p99.
- [ ] Rejoin creates a new membership epoch (no ambiguous replay across epochs).
- [ ] Queries expose active membership epoch for debugging and explainability.
**Depends On:** Phase 1
**Complexity:** L
@ -2963,10 +2724,10 @@ Then:
**Acceptance Criteria:**
- [x] `purge_prior_contributions(user_id, community_id, epoch_range)` API implemented.
- [x] Purge writes tombstones and triggers deterministic rematerialization job.
- [x] Rebuilt aggregates are identical across repeated replay of same purge operation.
- [x] Community queries include purge watermark metadata for auditability.
- [ ] `purge_prior_contributions(user_id, community_id, epoch_range)` API implemented.
- [ ] Purge writes tombstones and triggers deterministic rematerialization job.
- [ ] Rebuilt aggregates are identical across repeated replay of same purge operation.
- [ ] Community queries include purge watermark metadata for auditability.
**Depends On:** Phase 2
**Complexity:** XL
@ -2977,7 +2738,7 @@ Users can opt into community personalization, leave safely, and purge prior cont
---
## Milestone 10: Governance & Agent Rights -- "Who can influence ranking, and how" — ✅ COMPLETE (2026-06-06)
## Milestone 10: Governance & Agent Rights -- "Who can influence ranking, and how"
### Milestone Thesis
@ -3014,10 +2775,10 @@ Then:
**Acceptance Criteria:**
- [x] Policy schema includes allowed intents, excluded intents, and weighting constraints.
- [x] Policy changes are versioned and applied with effective timestamps.
- [x] Query results can return governing policy version for explanation.
- [x] Out-of-policy signals are rejected or quarantined by rule.
- [ ] Policy schema includes allowed intents, excluded intents, and weighting constraints.
- [ ] Policy changes are versioned and applied with effective timestamps.
- [ ] Query results can return governing policy version for explanation.
- [ ] Out-of-policy signals are rejected or quarantined by rule.
**Depends On:** M9
**Complexity:** L
@ -3028,10 +2789,10 @@ Then:
**Acceptance Criteria:**
- [x] Agent capability tokens include scope permissions and TTL.
- [x] Reads/writes outside granted scope return policy errors and audit events.
- [x] Revocation invalidates capabilities immediately (< 1s p99).
- [x] `tidalctl` can inspect agent capabilities and revocation history.
- [ ] Agent capability tokens include scope permissions and TTL.
- [ ] Reads/writes outside granted scope return policy errors and audit events.
- [ ] Revocation invalidates capabilities immediately (< 1s p99).
- [ ] `tidalctl` can inspect agent capabilities and revocation history.
**Depends On:** Phase 1
**Complexity:** L
@ -3042,10 +2803,10 @@ Then:
**Acceptance Criteria:**
- [x] Every ranking-affecting signal has provenance metadata (`writer`, `scope`, `policy_version`, `membership_epoch`).
- [x] `remove_from_personalization(scope=...)` API supports precise, non-global deletion.
- [x] Explainability endpoint can attribute top-ranked items to policy-allowed signals.
- [x] Replay/failover preserves remove-by-scope outcomes deterministically.
- [ ] Every ranking-affecting signal has provenance metadata (`writer`, `scope`, `policy_version`, `membership_epoch`).
- [ ] `remove_from_personalization(scope=...)` API supports precise, non-global deletion.
- [ ] Explainability endpoint can attribute top-ranked items to policy-allowed signals.
- [ ] Replay/failover preserves remove-by-scope outcomes deterministically.
**Depends On:** Phase 2
**Complexity:** XL
@ -3056,46 +2817,6 @@ Communities and users can control ranking influence with explicit governance and
---
## Implementation Status (M9M10 as-built) — 2026-06-06
M9 and M10 are implemented in the `tidaldb` engine crate under a new
`tidal/src/governance/` module, plus `db/communities.rs`, `db/capabilities.rs`,
`db/remove_scope.rs`, WAL **format v3**, and the storage `Tag` band `0x0E0x13`.
| Phase | Status | Key surfaces |
|-------|--------|--------------|
| m9p1 Signal Scope & Share Contract | ✅ | `SignalScope`/`CommunityId`/`SignalProvenance`/`SharePolicy`; WAL v3 `EventRecord` (21→32B, v1/v2 still decode); `TidalDb::signal_scoped`; shipper `community_share_only` filter; `tidalctl scope-stats` |
| m9p2 Membership Lifecycle & Stop-Forward | ✅ | join/leave/rejoin, O(1) atomic stop-forward gate, epochs, `Tag::Membership` persist, gated `signal_for_community` |
| m9p3 Retroactive Purge & Rematerialization | ✅ | per-contributor `CommunityLedger`, `purge_prior_contributions`, deterministic rematerialization (BLAKE3-digest tested), tombstones + watermark, reopen survival |
| m10p1 Community Governance Policy Engine | ✅ | versioned `GovernancePolicy` (monotonic + effective ts, weighting bounds, intent eligibility), write-path enforcement, persist |
| m10p2 Agent Capability & Scope Controls | ✅ | `CapabilityToken`/`ScopeClass`, grant/revoke, atomic <1s revocation, TTL, `agent_signal_for_community`, audit + persist |
| m10p3 Provenance, Explainability, Remove-by-Scope | ✅ | writer-agent provenance, `explain_community_contributions`, `remove_from_personalization` (by user / by agent) |
**Verification:** `tidaldb` lib 1,311 unit tests + 30 new M9/M10 integration tests
(`tests/m9p1_scopes.rs`, `m9p2_membership.rs`, `m9p3_purge.rs`,
`m10p1_governance.rs`, `m10p2_capabilities.rs`, `m10p3_provenance.rs`) pass;
`cargo clippy -p tidaldb --lib -- -D warnings` clean; `cargo build --workspace` green.
**Known deltas vs. the per-phase acceptance text:**
1. **Query metadata.** `Results.policy_metadata` (membership_epoch / purge_watermark /
community_id / policy_version / effective_at_ns) and `QueryStats.membership_epoch`
exist and default cleanly, and every value is reachable via direct read APIs
(`membership_epoch`, `governing_policy_version`, `community_purge_watermark`,
`read_community_decay_score`/`read_community_windowed_count`,
`explain_community_contributions`). Folding these *inline into a single blended
local+community RETRIEVE* is the community-overlay UAT capstone (see *Next*), not
yet wired into the RETRIEVE executor.
2. **tidalctl.** `scope-stats` (WAL-based, lock-free) shipped. Capability/revocation
inspection is via the DB API (`agent_capabilities`, `revocation_history`) rather
than `tidalctl` subcommands, because `tidalctl` is intentionally lock-free and
does not open the items keyspace (consistent with its existing `diagnostics` design).
3. **WAL v3 `writer_agent`.** The community ledger carries the full writer-agent
string for remove-by-agent; the WAL v3 envelope's `writer_agent: u16` is reserved
(interning deferred). Provenance is authoritative in the ledger.
---
## Use Case Coverage Progression
| UC | Description | M1 | M2 | M3 | M4 | M5 | M6 | M7 |
@ -3173,32 +2894,21 @@ m1p1 (Types/Schema) ✓
M6 COMPLETE ✓ (6 phases: cohort, social, sorts, collections, scope, notifications)
M7 COMPLETE ✓ (crash recovery, degradation, scale, observability, UAT + enterprise readiness)
M8 Distributed Fabric:
In-Process Primitives (COMPLETE):
M8 IN PROGRESS (Distributed Fabric):
m8p1 (Shard-Aware Foundations) ✓
|
+---> m8p2 (WAL Shipping + Follower Replay) ✓
| |
+---> m8p3 (CRDT Reconciliation) ✓
|
+---> m8p4 (Session Continuity)
+---> m8p4 (Session Continuity) ← NEXT
| |
+-------+---> m8p5 (Control Plane + Multi-Tenancy)
+-------+---> m8p5 (Control Plane + Multi-Tenancy)
|
+---> m8p6 (In-Process UAT) ✓
+---> m8p6 (End-to-End UAT)
Multi-Node (COMPLETE):
m8p7 (Network Transport / gRPC) ✓
|
+---> m8p8 (Multi-Node tidal-server) ✓
|
+---> m8p9 (Cross-Node Query Routing) ✓
|
+---> m8p10 (Multi-Node UAT) ✓
M9 ✓ COMPLETE (m9p1 scope+share, m9p2 membership+stop-forward, m9p3 purge+rematerialize)
M10 ✓ COMPLETE (m10p1 governance policy, m10p2 agent capabilities, m10p3 provenance+remove-by-scope)
M9 phases depend on M8
M10 phases depend on M9
```
**Parallelization opportunities:**
@ -3211,7 +2921,6 @@ m1p1 (Types/Schema) ✓
- m8p2 (WAL Shipping) and m8p3 (CRDT Reconciliation) can be built in parallel after m8p1 (both complete)
- m8p4 (Session Continuity) tasks 01 and 02 are parallelizable within the phase
- m8p5 (Multi-Tenancy) tasks 01 and 02 are parallelizable within the phase
- m8p8 (Multi-Node Server) and m8p9 (Cross-Node Queries) are sequential after m8p7 (transport); m8p10 (Multi-Node UAT) depends on all three
---

Some files were not shown because too many files have changed in this diff Show More