plan: cycle 0002 node contract and ctx
Five tasks, each leaving the workspace green: 1. AnyColumn read-side as_f64/as_i64/as_bool/as_ts (mirror of as_*_mut). 2. Ctx<'a> + typed financial-indexed window accessors + tests. 3. Node trait + NodeSchema + InputSpec; lib.rs wiring + roadmap doc update. 4. Sma worked node in aura-std + hand-driven tests. 5. Workspace gate: build / test (20) / clippy -D warnings / purity grep. Ordered so each compilation unit builds after its task (accessors → Ctx → Node → Sma). Verbatim Rust + exact cargo commands per the planner Iron Law.
This commit is contained in:
@@ -0,0 +1,545 @@
|
||||
# Node Contract and Ctx — Implementation Plan
|
||||
|
||||
> **Parent spec:** `docs/specs/0002-node-contract-and-ctx.md`
|
||||
>
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
|
||||
> this plan. Steps use `- [ ]` checkboxes for tracking.
|
||||
|
||||
**Goal:** Add the `Node` contract (`schema`/`eval`), the `Ctx` read-side, the
|
||||
read-side `AnyColumn` accessors, and one worked `Sma` node — each task leaving the
|
||||
workspace green.
|
||||
|
||||
**Architecture:** Two new modules in `aura-core` (`ctx`, `node`) plus read-side
|
||||
accessors on `AnyColumn`; one worked node (`Sma`) in `aura-std`. Tasks are ordered
|
||||
so every compilation unit builds after each task: read-side accessors first (self
|
||||
-contained), then `Ctx` (needs the accessors), then `Node` (needs `Ctx`), then
|
||||
`Sma` (needs both), then the workspace gate.
|
||||
|
||||
**Tech Stack:** `aura-core` (no deps), `aura-std` (depends on `aura-core`), Rust
|
||||
2024, `cargo build/test/clippy --workspace`.
|
||||
|
||||
---
|
||||
|
||||
**Files this plan creates or modifies:**
|
||||
|
||||
- Modify: `crates/aura-core/src/any.rs` — add read-side `as_f64/as_i64/as_bool/as_ts` inside `impl AnyColumn` (before the closing brace at `:123`), + a read-accessor test in the existing `mod tests`.
|
||||
- Create: `crates/aura-core/src/ctx.rs` — `Ctx<'a>` borrow-wrapper + typed window accessors + tests.
|
||||
- Create: `crates/aura-core/src/node.rs` — `Node` trait, `NodeSchema`, `InputSpec`.
|
||||
- Modify: `crates/aura-core/src/lib.rs` — `mod`/`pub use` for `ctx` then `node`; roadmap doc-comment update.
|
||||
- Create: `crates/aura-std/src/sma.rs` — `Sma` worked node + hand-driven tests.
|
||||
- Modify: `crates/aura-std/src/lib.rs:15-17` — replace the "no API yet" paragraph with `mod sma; pub use sma::Sma;`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `AnyColumn` read-side accessors
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/aura-core/src/any.rs`
|
||||
|
||||
- [ ] **Step 1: Add the four read-side accessors**
|
||||
|
||||
In `crates/aura-core/src/any.rs`, inside the `impl AnyColumn` block, immediately
|
||||
after the `as_ts_mut` method (ends at `:122`) and before the block's closing brace
|
||||
(`:123`), insert:
|
||||
|
||||
```rust
|
||||
|
||||
/// Read-side concrete-column accessor (the symmetric partner of
|
||||
/// `as_*_mut`); `Some` only for the matching kind. `Ctx` uses these to hand
|
||||
/// a typed window from a type-erased edge.
|
||||
pub fn as_i64(&self) -> Option<&Column<i64>> {
|
||||
match self {
|
||||
AnyColumn::I64(c) => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_f64(&self) -> Option<&Column<f64>> {
|
||||
match self {
|
||||
AnyColumn::F64(c) => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_bool(&self) -> Option<&Column<bool>> {
|
||||
match self {
|
||||
AnyColumn::Bool(c) => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_ts(&self) -> Option<&Column<Timestamp>> {
|
||||
match self {
|
||||
AnyColumn::Ts(c) => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(`Column` and `Timestamp` are already imported at `any.rs:5` and `:7`.)
|
||||
|
||||
- [ ] **Step 2: Add a read-accessor test**
|
||||
|
||||
In `crates/aura-core/src/any.rs`, inside the existing `#[cfg(test)] mod tests`
|
||||
(which already has `use super::*;` at `:127`), before its closing brace, add:
|
||||
|
||||
```rust
|
||||
|
||||
#[test]
|
||||
fn read_accessor_matches_only_its_kind() {
|
||||
let f = AnyColumn::with_capacity(ScalarKind::F64, 2);
|
||||
assert!(f.as_f64().is_some());
|
||||
assert!(f.as_i64().is_none());
|
||||
assert!(f.as_bool().is_none());
|
||||
assert!(f.as_ts().is_none());
|
||||
|
||||
let i = AnyColumn::with_capacity(ScalarKind::I64, 2);
|
||||
assert!(i.as_i64().is_some());
|
||||
assert!(i.as_f64().is_none());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the crate builds and the new test passes**
|
||||
|
||||
Run: `cargo test -p aura-core read_accessor_matches_only_its_kind`
|
||||
Expected: PASS (`test result: ok. 1 passed`).
|
||||
|
||||
- [ ] **Step 4: Verify nothing else regressed**
|
||||
|
||||
Run: `cargo test -p aura-core`
|
||||
Expected: PASS — 15 tests (the prior 14 + the new one).
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `Ctx` — the read-side evaluation context
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/aura-core/src/ctx.rs`
|
||||
- Modify: `crates/aura-core/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: Create `crates/aura-core/src/ctx.rs`**
|
||||
|
||||
```rust
|
||||
//! The evaluation context (C8): the read-side window access a node sees in
|
||||
//! `eval`. The engine sizes and types each input from the node's `schema` at
|
||||
//! wiring, so the typed accessors below treat a kind mismatch as an engine bug
|
||||
//! (panic), not a user-facing error.
|
||||
|
||||
use crate::{AnyColumn, Timestamp, Window};
|
||||
|
||||
/// Read-only, zero-copy view of a node's inputs for one `eval`, in schema
|
||||
/// order. `Copy` because it is just a borrow of the input slice.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Ctx<'a> {
|
||||
inputs: &'a [AnyColumn],
|
||||
}
|
||||
|
||||
impl<'a> Ctx<'a> {
|
||||
/// Wrap the per-input columns (in schema-declared order) for one `eval`.
|
||||
pub fn new(inputs: &'a [AnyColumn]) -> Self {
|
||||
Self { inputs }
|
||||
}
|
||||
|
||||
/// Zero-copy `f64` window into input `i` (index 0 = newest). Panics if input
|
||||
/// `i` is not an `f64` edge — a wiring bug, never reachable from a correctly
|
||||
/// wired graph.
|
||||
pub fn f64_in(&self, i: usize) -> Window<'a, f64> {
|
||||
let inputs: &'a [AnyColumn] = self.inputs;
|
||||
inputs[i]
|
||||
.as_f64()
|
||||
.expect("input kind mismatch (checked at wiring) — engine bug")
|
||||
.window()
|
||||
}
|
||||
|
||||
/// Zero-copy `i64` window into input `i` (index 0 = newest). See `f64_in`.
|
||||
pub fn i64_in(&self, i: usize) -> Window<'a, i64> {
|
||||
let inputs: &'a [AnyColumn] = self.inputs;
|
||||
inputs[i]
|
||||
.as_i64()
|
||||
.expect("input kind mismatch (checked at wiring) — engine bug")
|
||||
.window()
|
||||
}
|
||||
|
||||
/// Zero-copy `bool` window into input `i` (index 0 = newest). See `f64_in`.
|
||||
pub fn bool_in(&self, i: usize) -> Window<'a, bool> {
|
||||
let inputs: &'a [AnyColumn] = self.inputs;
|
||||
inputs[i]
|
||||
.as_bool()
|
||||
.expect("input kind mismatch (checked at wiring) — engine bug")
|
||||
.window()
|
||||
}
|
||||
|
||||
/// Zero-copy `timestamp` window into input `i` (index 0 = newest). See
|
||||
/// `f64_in`.
|
||||
pub fn ts_in(&self, i: usize) -> Window<'a, Timestamp> {
|
||||
let inputs: &'a [AnyColumn] = self.inputs;
|
||||
inputs[i]
|
||||
.as_ts()
|
||||
.expect("input kind mismatch (checked at wiring) — engine bug")
|
||||
.window()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Scalar, ScalarKind};
|
||||
|
||||
#[test]
|
||||
fn ctx_hands_financial_indexed_windows() {
|
||||
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 4)];
|
||||
for v in [10.0_f64, 20.0, 30.0] {
|
||||
inputs[0].push(Scalar::F64(v)).unwrap();
|
||||
}
|
||||
let ctx = Ctx::new(&inputs);
|
||||
let w = ctx.f64_in(0);
|
||||
assert_eq!(w.len(), 3);
|
||||
assert_eq!(w[0], 30.0); // newest
|
||||
assert_eq!(w[2], 10.0); // oldest
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctx_addresses_multiple_inputs() {
|
||||
let mut inputs = vec![
|
||||
AnyColumn::with_capacity(ScalarKind::F64, 2),
|
||||
AnyColumn::with_capacity(ScalarKind::I64, 2),
|
||||
];
|
||||
inputs[0].push(Scalar::F64(1.5)).unwrap();
|
||||
inputs[1].push(Scalar::I64(42)).unwrap();
|
||||
let ctx = Ctx::new(&inputs);
|
||||
assert_eq!(ctx.f64_in(0)[0], 1.5);
|
||||
assert_eq!(ctx.i64_in(1)[0], 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "engine bug")]
|
||||
fn ctx_panics_on_kind_mismatch() {
|
||||
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::I64, 2)];
|
||||
inputs[0].push(Scalar::I64(7)).unwrap();
|
||||
let ctx = Ctx::new(&inputs);
|
||||
let _ = ctx.f64_in(0); // wrong kind → panic
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wire `ctx` into `lib.rs`**
|
||||
|
||||
In `crates/aura-core/src/lib.rs`, add `mod ctx;` to the module block. Replace:
|
||||
|
||||
```rust
|
||||
mod any;
|
||||
mod column;
|
||||
mod error;
|
||||
mod scalar;
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```rust
|
||||
mod any;
|
||||
mod column;
|
||||
mod ctx;
|
||||
mod error;
|
||||
mod scalar;
|
||||
```
|
||||
|
||||
Then add the re-export. Replace:
|
||||
|
||||
```rust
|
||||
pub use any::AnyColumn;
|
||||
pub use column::{Column, Window};
|
||||
pub use error::KindMismatch;
|
||||
pub use scalar::{Scalar, ScalarKind, Timestamp};
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```rust
|
||||
pub use any::AnyColumn;
|
||||
pub use column::{Column, Window};
|
||||
pub use ctx::Ctx;
|
||||
pub use error::KindMismatch;
|
||||
pub use scalar::{Scalar, ScalarKind, Timestamp};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the new context tests pass**
|
||||
|
||||
Run: `cargo test -p aura-core ctx_`
|
||||
Expected: PASS — 3 tests (`ctx_hands_financial_indexed_windows`,
|
||||
`ctx_addresses_multiple_inputs`, `ctx_panics_on_kind_mismatch`).
|
||||
|
||||
- [ ] **Step 4: Verify the crate still builds clean**
|
||||
|
||||
Run: `cargo test -p aura-core`
|
||||
Expected: PASS — 18 tests (15 from Task 1 + 3 new).
|
||||
|
||||
---
|
||||
|
||||
### Task 3: The `Node` trait
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/aura-core/src/node.rs`
|
||||
- Modify: `crates/aura-core/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: Create `crates/aura-core/src/node.rs`**
|
||||
|
||||
```rust
|
||||
//! The node contract (C8): the interface every node implements. A node declares
|
||||
//! its inputs and output kind via `schema`, and computes one cycle's output via
|
||||
//! `eval`. Firing policy (C6) and tunable params (C12/C19) are deliberately not
|
||||
//! part of the schema yet — see spec 0002's "Out of scope".
|
||||
|
||||
use crate::{Ctx, Scalar, ScalarKind};
|
||||
|
||||
/// One declared input of a node: its scalar kind and the lookback depth the
|
||||
/// engine must pre-size for it (must be >= 1).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct InputSpec {
|
||||
pub kind: ScalarKind,
|
||||
pub lookback: usize,
|
||||
}
|
||||
|
||||
/// A node's declared interface: its inputs (in order) and its single output
|
||||
/// kind. Built once at wiring, never on the hot path — the `Vec` is fine here.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct NodeSchema {
|
||||
pub inputs: Vec<InputSpec>,
|
||||
pub output: ScalarKind,
|
||||
}
|
||||
|
||||
/// The universal composable dataflow unit (C8): at most one output, a producer
|
||||
/// or transformer. `schema` declares the interface; `eval` computes one cycle's
|
||||
/// output (`None` = filter / not-yet-warmed-up). `&mut self` because a node may
|
||||
/// keep its own derived state.
|
||||
pub trait Node {
|
||||
fn schema(&self) -> NodeSchema;
|
||||
fn eval(&mut self, ctx: Ctx<'_>) -> Option<Scalar>;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wire `node` into `lib.rs`**
|
||||
|
||||
In `crates/aura-core/src/lib.rs`, add `mod node;` to the module block. Replace:
|
||||
|
||||
```rust
|
||||
mod any;
|
||||
mod column;
|
||||
mod ctx;
|
||||
mod error;
|
||||
mod scalar;
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```rust
|
||||
mod any;
|
||||
mod column;
|
||||
mod ctx;
|
||||
mod error;
|
||||
mod node;
|
||||
mod scalar;
|
||||
```
|
||||
|
||||
Then add the re-export. Replace:
|
||||
|
||||
```rust
|
||||
pub use any::AnyColumn;
|
||||
pub use column::{Column, Window};
|
||||
pub use ctx::Ctx;
|
||||
pub use error::KindMismatch;
|
||||
pub use scalar::{Scalar, ScalarKind, Timestamp};
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```rust
|
||||
pub use any::AnyColumn;
|
||||
pub use column::{Column, Window};
|
||||
pub use ctx::Ctx;
|
||||
pub use error::KindMismatch;
|
||||
pub use node::{InputSpec, Node, NodeSchema};
|
||||
pub use scalar::{Scalar, ScalarKind, Timestamp};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update the roadmap doc-comment**
|
||||
|
||||
In `crates/aura-core/src/lib.rs`, replace the "Still to come" paragraph:
|
||||
|
||||
```rust
|
||||
//! Still to come (subsequent cycles): the `Node` trait and its `schema`/`eval`,
|
||||
//! the evaluation context `Ctx`, the firing policies (A: fire-on-any-fresh +
|
||||
//! hold; B: all-fresh barrier), and the deterministic sim loop.
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```rust
|
||||
//! Delivered in cycle 0002 — the node contract:
|
||||
//!
|
||||
//! - [`Node`] — the `schema`/`eval` contract every node implements (C8), with
|
||||
//! [`NodeSchema`] / [`InputSpec`] declaring inputs (kind + lookback) and the
|
||||
//! single output kind;
|
||||
//! - [`Ctx`] — the per-`eval` read-side: zero-copy, financial-indexed [`Window`]
|
||||
//! access into each input (closing the cycle-0001 read-side gap on
|
||||
//! [`AnyColumn`]).
|
||||
//!
|
||||
//! Still to come (subsequent cycles): the firing policies (A: fire-on-any-fresh
|
||||
//! + hold; B: all-fresh barrier), the deterministic sim loop, sources, and
|
||||
//! ingestion.
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify `aura-core` builds with the trait present**
|
||||
|
||||
Run: `cargo build -p aura-core`
|
||||
Expected: `Finished` — 0 errors, 0 warnings.
|
||||
|
||||
- [ ] **Step 5: Verify the full core suite still passes**
|
||||
|
||||
Run: `cargo test -p aura-core`
|
||||
Expected: PASS — 18 tests (unchanged; `node.rs` adds types, no new core test —
|
||||
the node is exercised from `aura-std` in Task 4).
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `Sma` — the worked producer node
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/aura-std/src/sma.rs`
|
||||
- Modify: `crates/aura-std/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: Create `crates/aura-std/src/sma.rs`**
|
||||
|
||||
```rust
|
||||
//! `Sma` — simple moving average over the last `length` values of one f64
|
||||
//! input. The walking skeleton's first worked node: it proves the `aura-core`
|
||||
//! `Node` contract is authorable from a downstream crate and evaluable with no
|
||||
//! engine present (the test drives it by hand, as the sim loop later will).
|
||||
|
||||
use aura_core::{Ctx, InputSpec, Node, NodeSchema, Scalar, ScalarKind};
|
||||
|
||||
/// Simple moving average over the last `length` values of one f64 input.
|
||||
pub struct Sma {
|
||||
length: usize,
|
||||
}
|
||||
|
||||
impl Sma {
|
||||
/// Build an SMA of window `length` (must be >= 1).
|
||||
pub fn new(length: usize) -> Self {
|
||||
assert!(length >= 1, "SMA length must be >= 1");
|
||||
Self { length }
|
||||
}
|
||||
}
|
||||
|
||||
impl Node for Sma {
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: self.length }],
|
||||
output: ScalarKind::F64,
|
||||
}
|
||||
}
|
||||
|
||||
fn eval(&mut self, ctx: Ctx<'_>) -> Option<Scalar> {
|
||||
let w = ctx.f64_in(0);
|
||||
if w.len() < self.length {
|
||||
return None; // not yet warmed up
|
||||
}
|
||||
let mut sum = 0.0;
|
||||
for k in 0..self.length {
|
||||
sum += w[k]; // index 0 = newest (financial indexing)
|
||||
}
|
||||
Some(Scalar::F64(sum / self.length as f64))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aura_core::AnyColumn;
|
||||
|
||||
#[test]
|
||||
fn sma_warms_up_then_tracks_the_window_mean() {
|
||||
let mut sma = Sma::new(3);
|
||||
let schema = sma.schema();
|
||||
|
||||
// size the input column from the schema, as the engine will at wiring
|
||||
let mut inputs = vec![AnyColumn::with_capacity(
|
||||
schema.inputs[0].kind,
|
||||
schema.inputs[0].lookback,
|
||||
)];
|
||||
|
||||
let feed = [1.0_f64, 2.0, 3.0, 4.0, 5.0];
|
||||
// means of [1,2,3], [2,3,4], [3,4,5] once warmed up
|
||||
let expect = [None, None, Some(2.0), Some(3.0), Some(4.0)];
|
||||
|
||||
for (v, want) in feed.iter().zip(expect) {
|
||||
inputs[0].push(Scalar::F64(*v)).unwrap();
|
||||
assert_eq!(sma.eval(Ctx::new(&inputs)), want.map(Scalar::F64));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sma_length_one_is_identity() {
|
||||
let mut sma = Sma::new(1);
|
||||
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
|
||||
|
||||
inputs[0].push(Scalar::F64(7.0)).unwrap();
|
||||
assert_eq!(sma.eval(Ctx::new(&inputs)), Some(Scalar::F64(7.0)));
|
||||
|
||||
inputs[0].push(Scalar::F64(9.0)).unwrap();
|
||||
assert_eq!(sma.eval(Ctx::new(&inputs)), Some(Scalar::F64(9.0)));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wire `sma` into `aura-std/src/lib.rs`**
|
||||
|
||||
In `crates/aura-std/src/lib.rs`, replace the final doc paragraph (`:15-17`):
|
||||
|
||||
```rust
|
||||
//! Types are intentionally absent until the first spec needs them (the
|
||||
//! walking-skeleton milestone). This crate documents intent; it does not yet
|
||||
//! define API.
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```rust
|
||||
//! The first block lands with the walking skeleton: [`Sma`], the simple moving
|
||||
//! average — a worked producer node proving the `aura-core` `Node` contract.
|
||||
|
||||
mod sma;
|
||||
pub use sma::Sma;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the SMA tests pass**
|
||||
|
||||
Run: `cargo test -p aura-std`
|
||||
Expected: PASS — 2 tests (`sma_warms_up_then_tracks_the_window_mean`,
|
||||
`sma_length_one_is_identity`).
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Workspace gate
|
||||
|
||||
**Files:** none (verification only).
|
||||
|
||||
- [ ] **Step 1: Full workspace build**
|
||||
|
||||
Run: `cargo build --workspace`
|
||||
Expected: `Finished` — 0 errors, 0 warnings.
|
||||
|
||||
- [ ] **Step 2: Full workspace test**
|
||||
|
||||
Run: `cargo test --workspace`
|
||||
Expected: PASS — 20 tests total (18 in `aura-core`, 2 in `aura-std`), 0 failed.
|
||||
|
||||
- [ ] **Step 3: Clippy, warnings-as-errors**
|
||||
|
||||
Run: `cargo clippy --workspace --all-targets -- -D warnings`
|
||||
Expected: `Finished` — no warnings.
|
||||
|
||||
- [ ] **Step 4: Surface-purity grep**
|
||||
|
||||
Run: `grep -rnE 'RefCell|Rc<|dyn Any' crates/*/src`
|
||||
Expected: no matches (exit code 1, no output) — the hot path stays free of
|
||||
type-erased payloads, reference counting, and interior mutability.
|
||||
Reference in New Issue
Block a user