Compare commits
No commits in common. "1092d34c395b9b6e40f02eb0ca2168d2a4c887ab" and "3cc798fc15dc443535675af47cae04a97f290f32" have entirely different histories.
1092d34c39
...
3cc798fc15
@ -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.
|
||||
@ -0,0 +1,5 @@
|
||||
task: establish-foundation-standards
|
||||
created: 2026-02-20
|
||||
phase: COMPLETE
|
||||
before_count: 5
|
||||
current_count: 0
|
||||
@ -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
|
||||
@ -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.
|
||||
@ -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
|
||||
@ -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
2
.gitignore
vendored
@ -34,5 +34,3 @@ Thumbs.db
|
||||
# Ephemeral / scratch
|
||||
tmp/
|
||||
.claude/worktrees/
|
||||
.sdlc/tools/*/index/
|
||||
.sdlc/telemetry.redb
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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.
|
||||
@ -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
|
||||
@ -1,4 +0,0 @@
|
||||
actions:
|
||||
- timestamp: 2026-03-03T06:23:11.759775Z
|
||||
action_type: harvest
|
||||
detail: librarian init
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
@ -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>
|
||||
@ -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')
|
||||
}
|
||||
@ -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>
|
||||
@ -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.
|
||||
@ -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
|
||||
@ -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.0–1.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)
|
||||
}
|
||||
@ -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 }`
|
||||
@ -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)
|
||||
}
|
||||
@ -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.
|
||||
@ -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:
|
||||
@ -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)
|
||||
}
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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)
|
||||
}
|
||||
@ -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`.
|
||||
99
AGENTS.md
99
AGENTS.md
@ -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
308
API.md
@ -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` |
|
||||
|
||||
39
CHANGELOG.md
39
CHANGELOG.md
@ -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
|
||||
|
||||
91
CLAUDE.md
91
CLAUDE.md
@ -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 — M0–M10 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/) (00–14) |
|
||||
| **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 **59520–59529** (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/ # 00–14 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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
420
Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"tidal",
|
||||
"tidal-net",
|
||||
"tidalctl",
|
||||
"tidal-server",
|
||||
"applications/forage/engine",
|
||||
|
||||
@ -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) |
|
||||
|
||||
11
README.md
11
README.md
@ -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 |
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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.
|
||||
@ -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 59520–59529 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).
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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"]
|
||||
@ -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"]
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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, M0–M8).
|
||||
|
||||
| # | 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 M0–M10, 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/`
|
||||
|
||||
- [M0–M10 code review — 2026-06-07](reviews/M0-M10-code-review-2026-06-07.md) — seven-dimension re-review, 88 verified findings
|
||||
- [M0–M10 code review — 2026-06-08](reviews/M0-M10-code-review-2026-06-08.md) — seven-dimension review, 142 findings (latest pass)
|
||||
- [M0–M10 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)
|
||||
@ -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.
|
||||
@ -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).
|
||||
@ -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.
|
||||
@ -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. |
|
||||
|
||||
|
||||
@ -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 1–3 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
|
||||
|
||||
@ -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** — m7p1–m7p4 + 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 (m8p1–p6) COMPLETE: shard routing, WAL shipping, CRDT reconciliation, session continuity, multi-tenancy, control plane. Multi-node (m8p7–p9) 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** — m8p1–m8p6 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)** — M1–M4 complete. M5 (Communication Brief) is in progress with core implementation live; acceptance validation pending.
|
||||
|
||||
**Engine status:** M0–M10 **COMPLETE**. M9 (Community Sync & Revocation) and M10 (Governance & Agent Rights) shipped 2026-06-06 — see *Implementation Status (M9–M10 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 1–6 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 7–10: 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 (M9–M10 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 `0x0E–0x13`.
|
||||
|
||||
| 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
Loading…
Reference in New Issue
Block a user