Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions changelog/protocol-changes.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,13 @@
"status": "unreleased",
"impact": "compatible",
"title": "Machine-readable protocol changelog",
"summary": "Adds GET /changelog/protocol backed by changelog/protocol-changes.json, validated against changelog/schema/protocol-changes.schema.json in CI and by npm run changelog:validate.",
"summary": "Adds GET /changelog/protocol backed by changelog/protocol-changes.json, validated against changelog/schema/protocol-changes.schema.json in CI and by npm run changelog:validate. Covers normal, failure, retry and authorization paths with automated tests and an operational runbook.",
"protocolSurfaces": [
{
"kind": "http",
"method": "GET",
"path": "/changelog/protocol",
"description": "Protocol-facing changes with impact, migration and version filters."
"description": "Protocol-facing changes with impact, migration and version filters. Unauthenticated (@Public); no token required."
},
{
"kind": "cli",
Expand All @@ -103,9 +103,9 @@
"required": false,
"automated": true,
"steps": [],
"notes": "No migration. Consumers that need upgrade warnings can poll the endpoint from the version they run."
"notes": "No migration. Consumers that need upgrade warnings can poll the endpoint from the version they run. The reload() API allows hot-swapping the changelog without a restart; on failure the previous document is preserved."
},
"relatedIssues": [126],
"relatedIssues": [126, 352],
"docs": ["docs/protocol-changelog.md"]
},
{
Expand Down
307 changes: 208 additions & 99 deletions docs/protocol-changelog.md
Original file line number Diff line number Diff line change
@@ -1,130 +1,239 @@
# Machine-readable protocol changelog

Issue: [#126](https://github.com/TRELLIS-STELLAR/Trellis-API/issues/126)
> Tracks every change to an HTTP route, WebSocket/GraphQL surface, configuration key, or
> operator command so callers can discover what an upgrade does to them without reading prose.

`CHANGELOG.md` is written for people reading a release note. This is the
machine-readable counterpart: every change to a route, a WebSocket or GraphQL
surface, a configuration key or an operator command, with its impact on existing
callers and the migration that applies.
Issue: [#352](https://github.com/TRELLIS-STELLAR/Trellis-API/issues/352) /
[#126](https://github.com/TRELLIS-STELLAR/Trellis-API/issues/126)

## Files
---

| Path | Purpose |
| --- | --- |
| `changelog/protocol-changes.json` | The changelog itself. |
| `changelog/schema/protocol-changes.schema.json` | JSON Schema (draft 2020-12) for the file. |
| `changelog/__fixtures__/` | Deliberately invalid files used by the tests. |
| `src/changelog/` | Validator, read model and HTTP endpoint. |
| `scripts/validate-protocol-changelog.ts` | Command-line validation. |
## Quick reference

## Using it
| Concern | Where |
|---|---|
| Changelog file | `changelog/protocol-changes.json` |
| JSON Schema | `changelog/schema/protocol-changes.schema.json` |
| Validator script | `npm run changelog:validate` |
| Runtime endpoint | `GET /changelog/protocol` |
| Service | `src/changelog/protocol-changelog.service.ts` |
| Controller | `src/changelog/changelog.controller.ts` |
| Types | `src/changelog/protocol-changelog.types.ts` |

```bash
curl "$API/changelog/protocol"
---

# What would an upgrade from the version I run do to me?
curl "$API/changelog/protocol?since=0.1.0"
## Endpoint

# Only the entries that could break me.
curl "$API/changelog/protocol?impact=breaking&impact=deprecation"
```
### `GET /changelog/protocol`

No authentication required (`@Public()`). The `StrategyAuthGuard` reads
`IS_PUBLIC_KEY` from the controller class and returns `true` without checking
a token.

**Query parameters**

The response adds what a caller needs to decide:
| Parameter | Type | Description |
|---|---|---|
| `since` | semver string | Return only entries with `version > since`. Callers pin the version they run and learn what an upgrade would do. |
| `impact` | enum[] | One or more of `breaking`, `deprecation`, `compatible`, `fix`, `security`. |
| `status` | `released` \| `unreleased` | Filter by release state. |

```json
**Response shape**

```jsonc
{
"schemaVersion": "1.0.0",
"project": "trellis-api",
"generatedAt": "2026-09-27T12:00:00.000Z",
"latestVersion": "0.2.0",
"highestImpact": "compatible",
"migrationRequired": false,
"highestImpact": "compatible", // worst impact in the returned set
"migrationRequired": false, // true when any entry forces caller action
"affectedPaths": ["/changelog/protocol", "/health/dependencies"],
"entries": [ ... ]
"entries": [ /* ProtocolChangeEntry[] newest first */ ]
}
```

`migrationRequired` is true when at least one matching entry is breaking or
declares a required migration, so a caller can branch on one boolean.
---

## Entry shape
## Normal path

1. `ProtocolChangelogService.onModuleInit()` reads and validates
`changelog/protocol-changes.json` on first start.
2. The document is cached in memory for the life of the process.
3. `GET /changelog/protocol` returns a filtered view with no database or network I/O.

---

## Failure path

| Failure | Behaviour |
|---|---|
| File missing | `loadProtocolChangelog` throws `Unable to read … at <path>`. The module logs the error but does not crash. Subsequent `getDocument()` calls re-throw until the file is restored and `reload()` is called. |
| File not valid JSON | Same as above; error message contains `is not valid JSON`. |
| File invalid (schema or semantic) | `validateProtocolChangelog` returns a violations array; `loadProtocolChangelog` throws listing every rule that was broken. |
| Schema version mismatch | Rejected with `unsupported-schema-version`. Upgrade the schema and re-run `npm run changelog:validate` before deploying. |

The endpoint never returns a partial or unchecked document. Either it serves the
last fully validated snapshot or the process logs an error at start-up.

---

## Retry / reload path

`ProtocolChangelogService.reload()` lets an operator hot-swap the changelog
file without restarting the process, e.g. after a hotfix deploy:

```bash
# 1. Edit / regenerate the changelog.
npm run changelog:validate

# 2. Send a reload signal to the running process.
# (Exact mechanism depends on your runtime: SIGHUP handler, admin endpoint, etc.)
```

**Rollback guarantee**: if the new file fails validation, `reload()` throws and
the previous in-memory document is preserved. Callers continue to get the last
known-good responses until the file is corrected.

```json
{
"version": "0.3.0",
"date": "2026-09-27",
"status": "unreleased",
"impact": "breaking",
"title": "Response envelope replaced by the disclosure block",
"summary": "Every reconciliation response is wrapped in a disclosure envelope.",
"protocolSurfaces": [
{
"kind": "http",
"method": "GET",
"path": "/reconcile/stellar/tx/{txid}",
"description": "The transaction payload moves under data."
}
],
"migration": {
"required": true,
"automated": false,
"steps": ["Send ?details=standard to opt into the new response envelope."],
"notes": "The legacy envelope is removed in 1.0.0."
},
"breakingChanges": [
{
"description": "The transaction payload moved under data.",
"replacement": "Read data.transaction instead of the top-level payload.",
"issue": 125
}
],
"relatedIssues": [125]
}
```
reload() called
├── loadProtocolChangelog() succeeds → cache replaced, new doc returned
└── loadProtocolChangelog() throws → previous doc retained, error re-thrown
```

---

- `impact`: `breaking`, `deprecation`, `compatible`, `fix` or `security`. It is
the worst case for a caller that does nothing.
- `status`: `unreleased` marks merged work that is not cut as a release yet.
- `migration.automated` says whether tooling applies the migration or a human
does.
- `protocolSurfaces` uses `path` for `http`/`websocket`/`graphql`/`cli` and `key`
for `config`.
## Authorization path

## Validation
The `GET /changelog/protocol` endpoint is intentionally unauthenticated:

The schema covers structure; a second layer covers the rules a schema cannot
express. Both run in CI and locally, and both are required to pass before the
API will serve the file.
- `ChangelogController` carries `@Public()` at the class level.
- `StrategyAuthGuard.canActivate()` reads `IS_PUBLIC_KEY` and returns `true`
without inspecting any `Authorization` header.
- No `@Roles()`, `@Permissions()`, or `KycGuard` annotations are present on
the controller or its handler.

This means **any client, including callers with no account**, can discover what
protocol changes exist before deciding whether to upgrade. Sensitive operational
data (e.g. migration steps) is designed to be public; do not add secrets to
changelog entries.

---

## Validator

```bash
# Validate the committed changelog (used by CI):
npm run changelog:validate

# Validate a different file:
npm run changelog:validate -- --file changelog/__fixtures__/breaking-entry.json

# Machine-readable output for pipeline scripts:
npm run changelog:validate -- --json
npm run changelog:validate -- --file path/to/other.json
npm run changelog:validate -- --now 2026-09-27T00:00:00Z # deterministic

# Pin the clock (deterministic CI):
npm run changelog:validate -- --now 2026-09-27T12:00:00Z
```

Exit codes: `0` = valid, `1` = semantic violations, `2` = unreadable or
non-JSON file.

The script and the runtime API share `validateProtocolChangelog()`, so a
file that passes the script cannot fail at runtime.

---

## Semantic rules

| Rule | When triggered |
|---|---|
| `breaking-without-description` | `impact: "breaking"` with no `breakingChanges` |
| `breaking-without-migration` | `impact: "breaking"` with `migration.required: false` |
| `migration-without-steps` | `migration.required: true` but `steps` is empty |
| `migration-step-not-actionable` | A step does not end with `.` |
| `deprecation-removal-not-after-version` | `removalVersion ≤ entry.version` |
| `deprecation-without-impact` | `deprecations` present but `impact` is not `deprecation` or `breaking` |
| `http-surface-without-method` | `kind: "http"` surface with no `method` |
| `surface-without-path` | HTTP/WebSocket/GraphQL surface with no `path` |
| `surface-without-key` | `kind: "config"` surface with no `key` |
| `duplicate-surface` | Same `kind + method + path` appears twice in one entry |
| `untraceable-entry` | No `relatedIssues` and no `relatedPullRequests` |
| `duplicate-entry` | Same `version + title` appears more than once |
| `entries-out-of-order` | Entries are not sorted newest first |
| `date-not-a-calendar-date` | `date` is not a real calendar day (e.g. `2026-02-30`) |
| `date-in-the-future` | `date` is more than one day ahead of the current UTC date |
| `unsupported-schema-version` | `schemaVersion` is not the version this build understands |

---

## Edge cases and recovery

### Deployment with a broken changelog

If `changelog/protocol-changes.json` is accidentally committed in an invalid
state, `onModuleInit` logs the error but **does not crash** the process. The
endpoint will throw until the service is hot-reloaded or restarted with a
corrected file. The `changelog:validate` CI step catches this before it reaches
production.

### Clock skew

The validator allows one day of tolerance for future-dated entries
(`FUTURE_DATE_TOLERANCE_DAYS = 1`). Entries marked `status: "unreleased"` are
expected to carry the date they are expected to ship; the rule is only enforced
once they are more than 24 hours into the future.

### CHANGELOG_PATH override

Set `CHANGELOG_PATH` to an absolute path or a path relative to the repository
root to point the service at a non-default file. Both the validator script and
the runtime read the same variable.

```bash
CHANGELOG_PATH=/var/trellis/protocol-changes.json npm run start:prod
```

| Rule | Why |
| --- | --- |
| `required`, `enum`, `pattern`, `additionalProperties` | Missing fields, bad values and typos fail instead of being ignored. |
| `date-not-a-calendar-date`, `date-in-the-future` | A typo such as `2026-02-30` or a date ahead of today is rejected. |
| `breaking-without-description` | A breaking entry must say what breaks. |
| `breaking-without-migration` | A breaking entry must set `migration.required`. |
| `migration-without-steps` | A required migration must list steps. |
| `migration-step-not-actionable` | A step must be a complete, imperative sentence. |
| `deprecation-without-impact` | Deprecations imply `impact: deprecation` or `breaking`. |
| `deprecation-removal-not-after-version` | A deprecation needs a removal version later than its own. |
| `surface-without-path`, `surface-without-key`, `http-surface-without-method` | A surface must identify what it changes. |
| `duplicate-surface` | The same surface is listed once per entry. |
| `untraceable-entry` | Every entry references an issue or a pull request. |
| `duplicate-entry` | A copy-pasted entry within one release is rejected. |
| `entries-out-of-order` | Entries are ordered newest first. |
| `unsupported-schema-version` | The file matches the schema this build understands. |

Validation runs with an injected clock, so `--now` makes a run reproducible
instead of failing the day after a release is dated.

## Adding an entry

1. Add an object to `changelog/protocol-changes.json`, newest version first,
with `relatedIssues` filled in.
2. Run `npm run changelog:validate`.
3. The endpoint picks it up on the next deploy; there is no generation step.
### Rollback

If a release introduces a changelog entry that must be reverted:

1. Remove or revert the entry in `changelog/protocol-changes.json`.
2. Run `npm run changelog:validate` to confirm the file is still valid.
3. Deploy the reverted file. If the process is already running, trigger
`reload()` (or restart the process).

The file is append-only by convention. Do not change the `status` of an
existing `released` entry; add a new entry with `impact: "fix"` or
`impact: "breaking"` and a migration block instead.

### Contract preservation

The response shape is versioned with `schemaVersion`. Consumers must check
`schemaVersion` before deserializing. Additive fields (new optional properties)
do not increment the schema version. Field removal or type changes require a
`schemaVersion` bump and a changelog entry with `impact: "breaking"`.

---

## Testing

```bash
# Unit + integration specs (validator + service + controller):
npx jest src/changelog --runInBand

# Executable validation (no Jest, exits non-zero on any violation):
npm run changelog:validate

# Full test suite:
npm test
```

Tests cover:

- Normal query path (unfiltered, `since`, `impact`, `status` filters)
- Failure path (missing file, non-JSON, invalid file)
- Retry / reload path (successful reload, rollback on failure, recovery after bad start)
- Authorization path (`@Public()` metadata present, guard bypasses auth, unauthenticated controller call succeeds)
- Structural schema rules (missing fields, unknown fields, bad enums, bad semver)
- Semantic rules (breaking changes, migrations, deprecations, surfaces, ordering, traceability)
Loading
Loading