# Contributing to EdgeBits

> Default branch: `dev0`. PRs target `dev0`. `main` is reserved for tagged releases.

## TL;DR

1. **One issue → one branch → one PR → one concern.** No accumulating multiple changes on a long-lived branch.
2. **TDD where it's greenfield.** Write the test first; it fails; implement to make it pass; refactor.
3. **Define interface boundaries** before writing the implementation. Routes depend on services depend on repositories depend on protocols/interfaces.
4. **CI green or no merge.** Build, parse, typecheck, tests must pass on the PR branch before merge.
5. **Commit messages and PR descriptions follow the conventions in this doc.**

## Workflow — issue → branch → PR

### 1. Open an issue

Pick the right template from `.github/ISSUE_TEMPLATE/`:

- **Bug** — something behaves incorrectly
- **Feature** — new capability
- **Tech-debt** — refactor / cleanup / migration

Each issue has an **acceptance criteria** field. Don't start work without one. If it's not clear what "done" looks like, the issue isn't ready.

### 2. Branch

Branch from `dev0`. Naming:

```
<type>/<issue-number>-<kebab-summary>
```

Types: `feat`, `fix`, `refactor`, `docs`, `chore`, `test`, `perf`, `ci`.

Examples:
- `feat/42-auth-day1-foundations`
- `fix/57-edge-sync-deploy-pull-retries`
- `docs/68-update-architecture-overview`

Keep branches **short-lived**. Open the PR within a day or two of branching, even as a draft. Long-lived branches accumulate concerns and become unmergeable.

### 3. Commits on the branch

Each commit on the branch is a **logical step toward the PR's single concern**. Conventional Commits style:

```
<type>(<scope>): <subject>

<body — why, not what>
```

Types: same as branch types. Scope examples: `gateway`, `edge-manager`, `edge-sync`, `fasten`, `ui`, `docs`, `ci`.

Examples:
- `feat(gateway): add UserRepository protocol`
- `feat(edge-manager): add tenant_id middleware extraction`
- `test(gateway): cover argon2 verify-with-rehash path`
- `fix(edge-sync): retry deploy-pull on 502`
- `docs(roadmap): update edge-auth-plan with multi-tenant decisions`

### 4. PR

Open the PR (use `.github/PULL_REQUEST_TEMPLATE.md`). Sections:

- **Closes #N** — link the issue
- **Scope** — single concern, plus what is *not* in this PR
- **Test plan** — what you ran, what evidence
- **Audit trail** — new audit codes? new fasten emit sites? note them
- **OpenAPI delta** — new routes? changed shape? note URL + verb + payload
- **Deferred items** — anything the issue mentioned that's deferred to a follow-up PR

Reviewers check: scope is single concern, tests prove the change, audit/OpenAPI changes are documented, deferred work has a follow-up issue link.

### 5. Merge

- **Default strategy: merge commit.** Preserves the commit history on the branch. The merge commit on `dev0` shows "Merge pull request #N from <branch>" with the issue link.
- Squash-merge OK for tiny PRs (single-commit cleanups). Not for multi-commit feature work.
- Force-push to update a PR is fine; force-push to `dev0` or `main` is not.

After merge:
- Issue auto-closes via "Closes #N" in the PR description
- Branch can be deleted (GitHub auto-deletes if configured)

## TDD expectation

For **greenfield code** (anything in a new module, new service, new connector, new UI page): tests are written *before* the implementation. PR includes both. CI gate enforces.

For **existing untested code**: not retrofitted as a single sweep. Backfill tests opportunistically when touching a module — your PR for `feat/42-auth-day1` adds tests for the auth code it introduces; it doesn't have to add tests for the buffer manager you didn't touch.

Test layers:

| Service / layer | Framework | Style |
|---|---|---|
| Edge gateway (Python) | `pytest` + `httpx.AsyncClient` for routes | Arrange-act-assert; mocks for repository protocols; real argon2 (fast enough at default params) |
| Edge core (Python) | `pytest` | Pure-function tests for envelope/transform/aggregate logic |
| Edge Manager API (Go) | stdlib `testing` + `httptest` | Table-driven; mock interfaces |
| Edge-sync (Go) | stdlib `testing` | Table-driven |
| SDK (Go) | stdlib `testing` | Public API surface coverage |
| UI (TypeScript/React) | `vitest` for unit; existing browser smoke for E2E | Mock the api.ts wrapper |

## Interface boundaries

Per [.claude/rules/coding-standards.md](.claude/rules/coding-standards.md): **interfaces in consumer packages, not provider packages**. Routes consume services via protocols; services consume repositories via protocols; tests inject mocks at any layer.

Concrete example for the auth sprint:

```python
# edge/gateway/src/services/auth/protocols.py — consumer-side
class UserRepository(Protocol):
    async def get_by_username(self, tenant: str, username: str) -> User | None: ...
    async def create(self, tenant: str, user: User) -> None: ...
    async def update(self, tenant: str, user: User) -> None: ...
    async def delete(self, tenant: str, username: str) -> None: ...

class PasswordHasher(Protocol):
    def hash(self, plaintext: str) -> str: ...
    def verify(self, hashed: str, plaintext: str) -> bool: ...
    def needs_rehash(self, hashed: str) -> bool: ...

class JWTSigner(Protocol):
    def sign(self, claims: dict) -> str: ...
    def verify(self, token: str) -> dict: ...

class Clock(Protocol):
    def now(self) -> datetime: ...
```

Routes depend on `AuthService(UserRepository, PasswordHasher, JWTSigner, Clock)`. Tests inject mocks. Production wires `SQLiteUserRepository`, `Argon2Hasher`, `AuthlibJWTSigner`, `SystemClock`.

```go
// edge-manager/api/internal/auth/auth.go — consumer-side
type UserStore interface {
    GetByUsername(ctx context.Context, tenant, username string) (*User, error)
    Create(ctx context.Context, tenant string, u *User) error
    Update(ctx context.Context, tenant string, u *User) error
    Delete(ctx context.Context, tenant, username string) error
}

type PasswordHasher interface { Hash(p string) (string, error); Verify(hashed, p string) error }
type JWTSigner interface { Sign(claims jwt.MapClaims) (string, error); Verify(token string) (jwt.MapClaims, error) }
type Clock interface { Now() time.Time }
```

## Commit message conventions

```
<type>(<scope>): <subject in imperative, lowercase, ≤72 chars>

<body — why, not what. Wrap at 72.>

<optional footer — e.g., Closes #N, BREAKING CHANGE: ...>
```

- **No emoji** in commits (per [coding-standards.md](.claude/rules/coding-standards.md))
- **No `TODO` references without an issue number**
- **Don't commit `console.log`, `print()`, `fmt.Println` debug artifacts**
- **Don't commit secrets** — `.env`, `credentials.json`, anything matching `**/*.pem`, `**/*.key`

## CI gates

Every PR runs `.github/workflows/ci.yml`:

- **Python** — AST parse all `.py` files; `ruff` lint; `pytest` (passes if no tests, fails on any test failure)
- **Go** — `go vet` + `go build ./...` + `go test ./...` for `edge-manager/api`, `edge/edge-sync`, `sdk/edge`, `sdk/edge-manager`, `edge/cli`, `edge/tui`, `edge-manager/cli`, `edge-manager/tui`
- **UI** — `npx tsc --noEmit` for `edge/ui` and `edge-manager/ui`

A failed CI gate blocks merge. Don't override; fix the underlying issue.

## Observability and audit (mandatory)

Every error path is logged via `structlog` (Py) / `slog` (Go). Every state-changing operation emits a `fasten.emit()` audit row with `actor + target + tenant + request_id`. New audit codes are declared in [audit_codes.py](edge/core/observability/audit_codes.py) (Python) and [edge-manager/api/internal/audit/codes.go](edge-manager/api/internal/audit/codes.go) (Go) **before** they're emitted.

See [.claude/rules/coding-standards.md](.claude/rules/coding-standards.md) "Observability (Mandatory)" section for the full rule set.

## Anti-patterns

Things that get a PR rejected, not just nitpicked:

- One PR with multiple unrelated changes (split it)
- Reaching past an interface boundary (route handler making raw SQL calls; service importing from another service's internal module)
- New feature without tests (greenfield only)
- New API endpoint without OpenAPI registration
- Audit-emitting code without a declared `AuditCode`
- Pre-release / alpha / beta dependency versions (per coding-standards.md "exact pins, no `>=`")
- Mocking the database in integration tests (use real SQLite/Postgres test instance)
- `--no-verify` on commit; `--force-push` to `dev0` or `main`

## edge-sync ↔ edge-manager are a cohesive pair

`edge-sync` (Go service in `edge/edge-sync/`) and `edge-manager` (Go service in `edge-manager/`) are **not independent concerns**. edge-sync is the DMZ-side poller that talks to edge-manager's API; they share contracts (heartbeat shape, deploy delta, sync delta, audit-sync delta, claim flow). Changes that touch the wire between them must update **both sides in the same PR**:

- API shape change at `edge-manager/api/` → corresponding client update at `edge/edge-sync/internal/poller/`
- New endpoint at edge-manager → new poll handler at edge-sync (and vice-versa for inbound flows like heartbeat)
- New audit code shared across the two sides → both `audit/codes.go` files updated
- OpenAPI spec on EM updated → edge-sync's HTTP client matches the new shape

When the PR description's **Scope** section says "edge-manager change," reviewers will check whether edge-sync needed an update too. A scope-only-on-one-side PR for a bilateral change is rejected.

The Edge Node (Python gateway) is a separate product. Changes there are *not* automatically coupled with edge-sync / edge-manager — only the wire-format-shared work is.

## Questions / discussion

Open a GitHub Discussion for design questions before writing code, especially for:
- New connector / egress / function blocks (architecture review)
- Anything that touches `fasten/` (cross-language SDK; coordinate with the fasten repo)
- Auth / RBAC changes (review against [docs/roadmap/edge-auth-plan.md](docs/roadmap/edge-auth-plan.md))
- Compliance-affecting changes (review against [docs/roadmap/compliance-plan.md](docs/roadmap/compliance-plan.md))
