Data schema and client state machine
Block identity
A block is the unit of review: heading, paragraph, list item, code fence
or frontmatter field. Its id is <context>:<type>:<key> as produced by
parseDocumentBlocks in the package-root semantic.js (T2.1 moves it there). The same function runs in
three places and must stay the single implementation:
- the service, to diff merge-request layers and commit history;
- the remark plugin (T2.2), to stamp
data-redline-blockon rendered HTML; - the tests, to compute expected ids.
A rendered element carries data-redline-block="<id>". Comments anchor to it,
redlines decorate it, and changed-since marks it.
Schema additions
All additions are optional fields on existing objects, so the /v1 contract
stays backward compatible. contracts/openapi.yaml is updated in T2.5.
Health
{
"ok": true,
"service": "fio-redline",
"apiVersion": "1",
"now": "2026-10-10T12:00:00.000Z",
"gitlab": { "reachable": true, "checkedAt": "2026-10-10T11:59:50.000Z", "status": 200 }
}
gitlab.reachable is false when the probe (GET <internal>/api/v4/version,
3 second timeout, cached 15 seconds) throws or returns a status of 502, 503
or 504. status is the HTTP status or null on a network error.
Error code gitlab-unreachable
Any service request that needs GitLab responds 502 with
{ error: { code: "gitlab-unreachable", message: … } } when the upstream fetch
throws (network error, timeout) or returns 502, 503 or 504. Other upstream
failures keep the existing gitlab-api-error code.
Commit blockChanges and history blockHistory
Each commit in history.commits gains:
"blockChanges": [{ "blockId": "title:paragraph:paragraph", "type": "replace" }]
type is insert, delete or replace. It is null (not an empty array)
when the service could not compute it, for example a document over the size
limit.
history gains an aggregate, newest first per block:
"blockHistory": {
"title:paragraph:paragraph": [
{ "sha": "bbbb…", "committedAt": "2026-07-29T12:00:00Z", "type": "replace" },
{ "sha": "aaaa…", "committedAt": "2026-07-28T12:00:00Z", "type": "insert" }
]
}
It covers only the commits returned in history.commits.
Identity mode
IDENTITY_MODE is cloudflare-access (default, current behaviour) or
gitlab-oauth. In gitlab-oauth mode the Cf-Access-* headers are never
read, GitLab OAuth is the only user identity, and the session’s accessEmail
is the GitLab user’s email. Reads are governed by ALLOW_ANONYMOUS_READ as
before. The /documents/state payload is unchanged; authentication.user is
the GitLab user when connected.
A third mode, tailscale, is permitted if OAuth blocks the journey; see
“Alternative identity” in network.md. In that mode authentication.user is
the tailnet user and gitlabConnected is true whenever the identity
headers are present, so the client machine is unchanged.
Client state machine
One store per (serviceUrl, siteId, documentPath) (T3.0) owns this machine.
Every component reads the store; no component fetches on its own.
States
| State | Meaning |
|---|---|
static | Nothing fetched yet. Build-time content is showing. |
probing | A fetch is in flight. Build-time content keeps showing. |
live | A DocumentState payload is loaded and usable. |
connect-required | Payload loaded; the site allows comments, the reader has no GitLab session. A substate of live: live data shows, plus the connect prompt. |
offline-network | The service host did not answer at all. On the experiment network this means the reader is off Tailscale. |
offline-gitlab | The service answered but GitLab did not. |
unauthenticated | The service answered 401. |
error | Any other failure. |
Classification
classifyRedlineFailure({ error, response, payload }) in client.js (T2.4):
erroris aTypeError, orresponseis absent →offline-network. (A browserfetchrejects withTypeErrorfor DNS failure, refused connection, and CORS failure. All three mean “could not reach”.)response.status === 502andpayload.error.code === 'gitlab-unreachable'→offline-gitlab.response.status === 401→unauthenticated.- otherwise →
error.
deriveRedlineState(payload) for a successful payload:
payload.capabilities.comment && !payload.permissions.comment && !payload.authentication.gitlabConnected→connect-required- otherwise →
live
Transitions
| From | Event | To |
|---|---|---|
static | load() | probing |
| any non-probing | load() | probing (previous payload retained) |
probing | response ok | live or connect-required |
probing | failure | the classified state |
live, connect-required | poll tick (footer open, interval > 0) | probing |
offline-*, unauthenticated, error | poll tick | probing (polling continues so recovery is automatic) |
connect-required | reader clicks connect | browser navigates to /v1/auth/gitlab/start?returnTo=<page>; on return the page reloads into static |
A load() while one is in flight returns the in-flight promise.
Messages
Defined once in client.js as REDLINE_MESSAGES and imported by components
and tests. Exact strings:
| State | Message |
|---|---|
static | (none) |
probing | Checking for live changes… |
live | Live · updated {time} where {time} is toLocaleTimeString() |
connect-required | Connect GitLab to comment. (rendered as a link to the start URL) |
offline-network | Live review needs the private network. Showing history from the last build. |
offline-gitlab | GitLab is unavailable. Showing history from the last build. |
unauthenticated | Sign in to see live review. |
error | Live review unavailable ({code}). where {code} is the error code or HTTP status |
Every component root element exposes data-redline-state="<state>" so tests
assert the state, then the message.
Invariants
- Build-time content never disappears. Live data only adds to it, or replaces it in place; on failure the build-time content is what remains.
- The connect link is shown only in
connect-required, which requires a successful response. A reader off the network never sees a link to a GitLab they cannot reach. - The store never retries faster than 5 seconds.
Write path
For the experiment the only write path is comments. Whatever store holds them,
the service enforces: an authenticated person behind every mutation; a
mutation bound to one document; body and anchor size limits; one
redline-audit log line per mutation with the actor. The existing GitLab
implementation already does all four. If an interim store replaces it, the
same four hold and a propagation task writes the interim records to GitLab
issues or merge-request discussions, attributed in the body to the person.
Drafts, approvals and merge remain feature-flagged off for the experiment
site.