Skip to content

buzz repos protect set and repos protect remove are rejected by the relay's timestamp drift check on any repo announced more than 15 minutes ago #2876

Description

@standing-order

Buzz Version 0.4.26 (0.4.26)
MacOS Sequoia 15.7.5

Summary
build_updated_repo_announcement deliberately stamps the replacement announcement with existing.created_at + 1 rather than wall-clock time. The relay rejects any event whose created_at differs from server time by more than ±900 seconds.

The two are incompatible after 15 minutes. Branch protection on a repo therefore becomes permanently unchangeable — cannot be set, cannot be tightened, cannot be removed — 15 minutes after the repo is announced, for the lifetime of that announcement.

Both halves are individually reasonable. The increment exists to prevent a delayed writer from leapfrogging an intervening update; the drift check exists to bound replay. Neither knows about the other.

The general form, which is why this may be worth more than the one command it was found on:

Any command that stamps a replacement event from an observed head can only work while that head is fresh. A relay that requires freshness and a client that derives timestamps from prior state are individually defensible and mutually exclusive by construction.

Worth grepping for other custom_created_at callers on the same pattern. In this file the only two hits are the update path (repos.rs:138) and a test helper (repos.rs:413) — repos create does not call it and therefore stamps wall clock, which is what makes the destructive escape hatch below mechanically real.

Environment
Source read at block/buzz commit a64cc71 (main); relay under test reports version: 0.2.0 via NIP-11 (version match, not commit-exact).
Repo announced at created_at 1784997112; command run at epoch 1785002466 (5,354 s / ~89 min later).
Reproduction

$ buzz repos protect set --id block-demos --ref refs/heads/main --no-force-push --no-delete {"error":"relay_error","message":"relay error 400: invalid: event timestamp too far from server time","retryable":false} ```

The rule being applied here is byte-identical to the rule already stored — this is a no-op re-application, and it is still rejected. Nothing about the request is malformed.

Local clock was verified against relay-hosted event timestamps before concluding this was not client skew: the newest event on the relay at the time carried created_at 1785002350, 116 s before the local clock's 1785002466. The client's clock is correct. The stale timestamp is constructed by the CLI.

Root cause
The CLI stamps existing + 1 — crates/buzz-cli/src/commands/repos.rs:129-138:

rust // Advance only the observed head. Using wall-clock time here would let a // delayed writer leapfrog an intervening update and silently erase metadata. let next_created_at = existing .created_at .as_secs() .checked_add(1) .ok_or_else(|| CliError::Other("repository timestamp cannot be advanced".into()))?; buzz_sdk::build_repo_announcement_with_tags(repo_id, &existing.content, tags) .map_err(...) .map(|builder| builder.custom_created_at(Timestamp::from(next_created_at)))

This is asserted by the crate's own unit test (repos.rs:410-449) — an existing event at created_at 100 yields a replacement at created_at 101.

The relay rejects >±900 s — crates/buzz-relay/src/handlers/ingest.rs:1506-1513:

rust const MAX_TIMESTAMP_DRIFT_SECS: i64 = 900; // ±15 minutes let now = chrono::Utc::now().timestamp(); let event_ts = event.created_at.as_secs() as i64; if (event_ts - now).abs() > MAX_TIMESTAMP_DRIFT_SECS { return Err(IngestError::Rejected( "invalid: event timestamp too far from server time".into(), )); }

This check is in ingest_event_inner, reached from ingest_event, which is the sole ingest entry point for both shipped paths — the HTTP bridge (crates/buzz-relay/src/api/bridge.rs:833) and the WebSocket EVENT handler (crates/buzz-relay/src/handlers/event.rs:728). There is no ingest path that skips it.

So the replacement is stamped 1784997113, which is 5,353 s from server time, and is refused.

Both protect set and protect remove go through the same builder — cmd_protect_set at repos.rs:323 and cmd_protect_remove at repos.rs:344-345 each call build_updated_repo_announcement with the fetched &event.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions