B6 Standalone
Stage 1: Redline serves its own dashboard and knowledge base beside its API,
on the tailnet, in a QA and a production environment, and is proven there
against real GitLab. Modelled on the Markdown Renderer service
(fio-design-markdown-renderer): one deployable, the docs rendered by the
thing they describe, build once and promote, persistent QA, smoke evidence per
environment, lifecycle records together. Design:
standalone.md.
T6.1 Dashboard site
- Wave: 7
- Depends on: T3.4, T3.5, T3.6
- Size: L
- Design: standalone.md (the dashboard)
- Touches:
site/**(new),GitHistoryFooter.astro,GitTrackChanges.astro,GitComments.astro,GitRedline.astro,GitChangedSince.astro,client.js(resolveServiceUrl),index.js,index.d.ts,package.json(scripts),.gitignore,.dockerignore,test/dashboard.test.js(new) - Why: the host that proves Redline on its own, with this repository’s
kb/as content.
Steps
client.js: exportresolveServiceUrl(value, base = globalThis.location?.href)returning an absolute origin-plus-path without a trailing slash: absolutehttp(s):values unchanged, a value starting with/resolved againstbase, anything else''.createRedlineClientandgetRedlineStoreresolve theirserviceUrlthrough it.- Each component’s frontmatter keeps an absolute
serviceUrlas today and also accepts one that starts with/; the<script>resolves it withresolveServiceUrl. The memoisation key ingetRedlineStoreuses the resolved URL. site/: an Astro 7 project ("@fridai/fio-redline": "file:..",@astrojs/markdown-remarkexact,astroexact, its ownpackage-lock.json) withmarkdown: { processor: unified() }andgitHistory({ blocks: true, contentRoots: [docsDir] })wheredocsDirisprocess.env.REDLINE_DOCS_DIR || 'kb'relative to the repository root.src/pages/index.astro: the overview: title, one paragraph on what Redline is, the build identity fromimport.meta.env.PUBLIC_REDLINE_VERSION(fallbackdevelopment), and a status line bound to the store (data-dashboard-status, text fromstore.message()).src/pages/kb/[...slug].astro: one page per Markdown file underdocsDir(index files map to their folder), with a navigation list of every page, the five components withserviceUrl="/",siteId={process.env.REDLINE_SITE_ID || 'fio-redline'}anddocumentPaththe file’s repository path, or, whenREDLINE_DOCUMENT_PREFIXis set, that prefix plus the file’s path insidedocsDir(the e2e build maps the fixture onto the mock’ssrc/content), and<article data-redline-document>holding the content. The footer getsmeta={getGitMeta(<repository path>)}andid="history".- Plain CSS only; no design-system dependency (the registry token cannot read packages, see BLOCKERS.md T0.6).
- Root
package.jsonscripts:"dashboard:build": "npm --prefix site run build","dev:dashboard": "npm --prefix site run dev". Ignoresite/node_modules/,site/dist/,site/.astro/in.gitignore; in.dockerignorekeepsite/excluded except!site/dist/and!site/dist/**.
Acceptance
test/dashboard.test.js:
service url resolves relative to the page(resolveServiceUrl('/', 'https://qa-yqa.fio.sh/kb/x/')ishttps://qa-yqa.fio.sh; absolute values unchanged;javascript:xgives'')dashboard builds every kb page with block ids(spawnsnpm run dashboard:build; for everykb/**/*.mda page exists undersite/dist/kb/;site/dist/kb/index.htmlcontainsdata-redline-block=anddata-git-redline)dashboard pages carry no absolute service host(no file undersite/distcontainslocalhost:8787oryqa.fio.shin adata-service-url)
npm test 2>&1 | grep -c "^✔ service url resolves\|^✔ dashboard" # prints 3
npm run test:e2e 2>&1 | tail -1 # all passed (relative serviceUrl does not break the fixture)
T6.2 Service serves the dashboard on its own origin
- Wave: 8
- Depends on: T6.1
- Size: M
- Design: standalone.md (one deployable)
- Touches:
service/src/static.js(new),service/src/server.js,service/Dockerfile,.gitlab-ci.yml(jobsite,needson the container job),test/static.test.js(new) - Why: one origin for dashboard and API removes CORS and cross-site cookies from the standalone path, and makes the image the single artifact.
Steps
service/src/static.js:createStaticHandler(directory)returningasync (request) => Response | null. It answers onlyGETandHEADfor paths outside/v1/; resolves/and/x/toindex.html; refuses any path that normalises outsidedirectory(404); serves the built 404 page with status 404 when the file is missing; setsContent-Typefrom the extension,X-Content-Type-Options: nosniff,Referrer-Policy: no-referrer,Cache-Control: public, max-age=31536000, immutableunder/_astro/andno-cacheelsewhere.server.js: whenSITE_DIRis set (the image sets/app/site), try the static handler before the API for non-/v1paths.Dockerfile:COPY --chown=node:node site/dist ./sitewhen present (the build fails loudly ifsite/distis missing in CI);ENV SITE_DIR=/app/site..gitlab-ci.yml: asitejob in stagebuild(imagenode:22-alpine,GIT_DEPTH: "0",apk add --no-cache git,npm ci,npm --prefix site ci,PUBLIC_REDLINE_VERSION=$CALVER_VERSION npm run dashboard:build, artifactssite/dist/, rules as the container job); the container job gainsneeds: [{ job: site, artifacts: true }]. Cite the job’s purpose in a comment in the file’s style. Confirm in the pipeline log that the image contains/app/site/index.html; if the catalog component cannot take the artifact, record a blocker rather than building the site inside Docker.
Acceptance
test/static.test.js:
static handler serves the dashboard index and assetsstatic handler refuses paths outside the site directory(/../package.json,/%2e%2e/package.json→ 404)static handler leaves the api to the service(/v1/health→null)node server serves the dashboard and the api on one origin(createNodeServerwithSITE_DIRpointing at a temp directory;GET /200 HTML,GET /v1/health200 JSON)
npm test 2>&1 | grep -c "^✔ static handler\|^✔ node server serves" # prints 4
npm run dashboard:build && npm run build:container && docker run --rm -d --name redline-t62 -p 8787:8787 --env-file <a valid env file> fio-redline:local && sleep 3 && curl -sf http://127.0.0.1:8787/ | grep -c data-dashboard-status && curl -sf http://127.0.0.1:8787/v1/health && docker rm -f redline-t62 # 1, then health JSON
T6.3 QA environment configuration
- Wave: 7
- Depends on: T4.1
- Size: S
- Design: standalone.md (artifact and environments)
- Touches:
ops/docker-compose.yml,ops/service.env.example,ops/service-qa.env.example(new),ops/.env.example(new),kb/setup/index.md(the experiment section),test/hardening.test.js - Why: QA tracks
:verifiedand production:released, on one host, from one compose file.
Steps
ops/docker-compose.yml: addservice-qaunderprofiles: ["qa"], the same hardening asservice,image: "${FIO_REDLINE_QA_IMAGE:?…}",env_file: ./service-qa.env,container_name: fio-redline-qa, network aliasfio-redline-qaoningressandgitlab, the same secrets exceptsession_secret_qa(its own file./secrets/session-secret-qa).ops/.env.example:FIO_REDLINE_IMAGE=glab.fio.sh:5050/fridai/fio-registry/fio-redline:released,FIO_REDLINE_QA_IMAGE=glab.fio.sh:5050/fridai/fio-registry/fio-redline:verified,GLAB_DOCKER_NETWORK=glab.fio.sh-bridge.ops/service-qa.env.example: the experiment values withPUBLIC_SERVICE_URL=https://qa-yqa.fio.shand aSITES_JSONregisteringfio-redline(gitlabProjectIdoffridai/fio-dep/fio-redline,gitlabProjectPath: "fridai/fio-dep/fio-redline",contentRoots: ["kb"],allowedOrigins: ["https://qa-yqa.fio.sh"],features.editing=false).ops/service.env.examplegains the samefio-redlinesite withhttps://yqa.fio.sh.kb/setup/index.md: extend the experiment section with the QA host, the two OAuth redirect URIs (one application, one URI per line) anddocker compose --profile qa up -d.test/hardening.test.js:compose qa service tracks the verified channel under its own profileexperiment env examples register the fio-redline site on their own origin(parse both examples;allowedOriginsequals the ownPUBLIC_SERVICE_URL)
Acceptance
npm test 2>&1 | grep -c "^✔ compose qa service\|^✔ experiment env examples" # prints 2
# with ops/.env and ops/service*.env copied from the examples:
docker compose -f ops/docker-compose.yml config --services # service
docker compose -f ops/docker-compose.yml --profile qa config --services # service, service-qa
T6.4 Dashboard end to end
- Wave: 9
- Depends on: T6.1, T6.2, T0.3
- Size: M
- Touches:
playwright.config.js(a second service on 8788 serving the dashboard),test/e2e/run-service.js(honourREDLINE_E2E_PORT,REDLINE_E2E_SITE_DIR,REDLINE_E2E_ALLOWED_ORIGIN),test/e2e/dashboard.spec.js(new),.gitlab-ci.yml(e2einstallssite/) - Why: proves the same-origin path in a browser against the mock, before any deployment.
Steps
- The dashboard is built for the fixture repository:
REDLINE_DOCS_DIR=test/fixtures/site/src/content REDLINE_SITE_ID=demo REDLINE_DOCUMENT_PREFIX=src/content npm run dashboard:build, so its one page’s document path issrc/content/doc.md, which the mock serves. - A fourth
webServerentry: build that dashboard, thennode test/e2e/run-service.jswithREDLINE_E2E_PORT=8788,REDLINE_E2E_SITE_DIR=site/dist,REDLINE_E2E_ALLOWED_ORIGIN=http://localhost:8788andPUBLIC_SERVICE_URL=http://localhost:8788;url: http://localhost:8788/v1/health. - Spec.
Acceptance
test/e2e/dashboard.spec.js:
dashboard serves a kb page and the api from one origin(http://localhost:8788/kb/doc/has[data-redline-block="title:paragraph:paragraph"]; every component root reachesliveorconnect-required; no request leaveslocalhost:8788except the OAuth redirect)dashboard shows the proposal inline(toggle!7; the block’sdelisRevised, itsinsisProposed)dashboard connects gitlab on its own origin(connect; comments statelive; the session cookie’s domain islocalhostand the comment form is visible)
npx playwright test test/e2e/dashboard.spec.js 2>&1 | tail -3 # "3 passed"
npm run test:e2e 2>&1 | tail -3 # "19 passed"
T6.5 Live smoke spec
- Wave: 9
- Depends on: T6.1, T6.2, T4.2
- Size: S
- Touches:
playwright.live.config.js(new),test/live/live.spec.js(new),package.json(scripttest:live) - Why: the browser half of the per-environment evidence: the same checks against QA and production, read-only.
Steps
playwright.live.config.js:testDir: 'test/live', nowebServer,use.baseURLfromREDLINE_LIVE_URL(required; the config throws a clear error when it is missing), one chromium project,workers: 1.test/live/live.spec.js(read-only; it never writes to GitLab):live health reports gitlab reachable(GET /v1/health:ok,gitlab.reachable)live dashboard renders the knowledge base with block ids(/kb/has at least one[data-redline-block])live components reach a live state(every component root on/kb/reachesliveorconnect-requiredwithin 15 seconds)live history lists commits(open the footer; at least one commit row)
"test:live": "playwright test -c playwright.live.config.js".
Acceptance
npm run test:live 2>&1 | tail -2 # without REDLINE_LIVE_URL: fails with the message naming the variable
REDLINE_LIVE_URL=http://localhost:8788 npx playwright test -c playwright.live.config.js 2>&1 | tail -1 # against the e2e dashboard service started by hand: "4 passed"
T6.6 Lifecycle records
- Wave: 7
- Depends on: none
- Size: M
- Touches:
kb/lifecycle/index.md,kb/lifecycle/roadmap.md,kb/lifecycle/tech-debt.md,kb/lifecycle/decisions/index.md,kb/lifecycle/decisions/ADR-*.md(all new),kb/index.md(link the section) - Why: the renderer keeps intent, evidence and the cost of change in one place (its ADR-0024). Redline’s decisions so far live only in commit bodies and
STATUS.mdnotes.
Steps
roadmap.mdin the renderer’s shape: Available now (what CI proves, with links), Adoption in progress (Stage 1 standalone, Stage 2 YYZ), Next, in priority order as a table of outcome and acceptance evidence, Decisions still needed (kb-package compatibility, service ownership).tech-debt.md, oneTD-NNNheading each, with status and evidence: TD-001 verify rulesets blocked on a package-read token (T0.6); TD-002 block ids for publish-time rendered kb-package pages (harvester); TD-003 history from kb-packagehistory.bundleinstead of the build checkout; TD-004 a service site spanning several source projects (page → project, path); TD-005 anonymous state responses arepublic, max-age=15(the store bypasses the cache; the header still misleads other clients); TD-006 the e2e suite runs on one worker because specs share one mock; TD-007package-npm-publishtags every candidatelatest, sonpm i @fridai/fio-redlinebypasses the verified/released gate the container image has.decisions/ADR-0001… in the renderer’s format (Status, Context, Decision, Why, Consequences, Evidence with commit SHAs, Revisit when), one each for: thegitlab-oauthidentity mode; one build-time history index; semantic block ids stamped from source; one shared client store; failure states named by edge; inline redline replaces the side list as the primary view; standalone dashboard and API on one origin (Stage 1 before YYZ); build once and promote, persistent QA.decisions/index.mdlists them.- No content is moved from
kb/plans/; the plan stays where it is.
Acceptance
ls kb/lifecycle/decisions/ADR-*.md | wc -l # 8
grep -c '^## TD-' kb/lifecycle/tech-debt.md # 7
grep -c '^## Revisit when' kb/lifecycle/decisions/ADR-*.md | grep -c ':1$' # 8
node ../fio-kb-harvester/bin/kb-harvester.mjs publish --version 0.0.0-check --out /tmp/kb-check && node ../fio-kb-harvester/bin/kb-harvester.mjs verify # "document governance OK."
(The last command needs a sibling checkout of fio-kb-harvester; if absent,
record that and rely on the kb-publish job after merge.)
T6.7 QA and production deployment and evidence (human)
- Wave: 10
- Depends on: T4.3, T6.3, T6.4, T6.5, and a
:verifiedimage containing every task up to wave 9; production additionally a:releasedimage (the operator promotes it) - Size: human
- Replaces: T4.4
The executor sets this task to human and lists in STATUS.md what the
operator provides. The operator performs, beyond T4.3:
- DNS and a host nginx TLS vhost for
qa-yqa.fio.sh→fio-redline-qa:8787, by the same method asyqa.fio.sh. - Add
https://qa-yqa.fio.sh/v1/auth/gitlab/callbackas a second redirect URI of the OAuth application. ops/.env,ops/service-qa.env,ops/secrets/session-secret-qafrom the examples;cd ops && docker compose --profile qa pull && docker compose --profile qa up -d.- QA evidence, from a tailnet device in this repository:
npm run smoke -- https://qa-yqa.fio.sh fio-redline kb/index.md(exit 0,liveorconnect-required), the same off the tailnet (exit 2,offline-network), andREDLINE_LIVE_URL=https://qa-yqa.fio.sh npm run test:live(4 passed). - Stage 1 demonstration on QA: open a merge request on
fio-redlinethat changes a paragraph underkb/; on its QA page press the toggle and see the change inline; connect GitLab and leave an anchored comment; see the note in the[Document Review] kb/…issue under your own account. Record the merge request and issue URLs and a screenshot path. - Promote to
released(manual job), then the same smoke and live checks againsthttps://yqa.fio.sh.
Evidence for STATUS.md: the smoke and live outputs per environment, both
image tags from docker compose ps, the merge request and issue URLs, the
screenshot path, and the date. No secrets, no cookies.
T6.8 Timeline scrubber
- Wave: 10
- Depends on: T6.1, T6.2, T3.5
- Size: L
- Design: operator decision 2026-10-11 (STATUS.md): a timeline like a camera app’s event scrubber. Commits sit left of now, open merge requests right of it. The playhead shows the page as it was at that point, with that point’s own change redlined (struck old text, highlighted new text); past now, a merge request marker shows the page as that proposal would make it.
- Touches:
semantic.js(anchorBlockIdon delete operations),remark-redline-blocks.js(data-redline-fingerprint),service/src/gitlab.jsandservice/src/index.js(GET /v1/sites/:site/documents/version),contracts/openapi.yaml,client.js(getDocumentVersion,renderVersion,restoreVersion),GitTimeline.astro(new),index.js,index.d.ts,package.json,site/src/pages/kb/[...slug].astro, tests below. - Why: the dashboard showed every capability as an empty box waiting for a click. Scrubbing through a page’s past and proposed future is the thesis made visible: changes reviewed in short bites on the rendered page.
Steps
diffDocumentBlocksgives eachdeleteoperationanchorBlockId: the id, in the after document, of the nearest preceding block that survives (matched or replaced), ornullat the top. Additive; existing fields unchanged.- The remark plugin also stamps
data-redline-fingerprint(the block’s fingerprint), so the browser can reuse rendered blocks that did not change. GET /v1/sites/:site/documents/version?path=&ref=<sha>&base=<sha?>: the document atrefas blocks (id,type,content,fingerprint) and the operations frombase(empty document when absent) toref. Both refs must be hexadecimal commit ids. Documents over the size limit answer 413.client.js:getDocumentVersionon the client;renderVersion(root, version)rebuilds the article from the version’s blocks (cloning rendered elements whose id and fingerprint match, rendering the rest from source text: headings by level, paragraphs, list items grouped in a list, code inpre), then redlines the version’s operations in place: replace → word diff, insert → whole block marked inserted, delete → struck block after itsanchorBlockId.restoreVersion(root)puts the original article back.GitTimeline.astro: a bar fixed to the bottom of the viewport with markers for each commit (by date), a now marker, and one marker per open merge request; a playhead that snaps to markers and moves by drag, wheel, arrow keys or click; a label with the point’s date, title and author. Moving to a commit loadsversion(ref=sha, base=first parent), to a merge requestversion(ref=headSha, base=baseSha); now restores the page. Versions are cached in the page; loads while scrubbing are debounced.- Dashboard pages carry GitTimeline instead of GitRedline, GitChangedSince and GitTrackChanges; comments and the history footer stay. Deliberately updates the T6.1 and T6.4 assertions that named the removed components.
Acceptance
Unit:
semantic delete operations carry the anchor block in the after documentremark plugin stamps block fingerprintsdocument version returns blocks at a commit and its change from the parentdocument version rejects refs that are not commit idsversion payload validates against the DocumentVersion schema
test/e2e/timeline.spec.js (dashboard on :8788):
timeline lists commits now and proposals in orderscrubbing to a commit shows the page as it was with that change redlinedscrubbing to a proposal shows the page as it would becomereturning to now restores the pagethe playhead moves with the keyboard
npm test 2>&1 | grep -c "^✔ semantic delete operations\|^✔ remark plugin stamps block fingerprints\|^✔ document version\|^✔ version payload" # prints 5
npx playwright test test/e2e/timeline.spec.js 2>&1 | tail -1 # "5 passed"
T6.9 Comments in the text, Word-style
- Wave: 10
- Depends on: T6.8, T3.4
- Size: L
- Design: operator decision 2026-10-11 (STATUS.md): comments work as in a word processor. Selecting text opens a small menu beside the selection with Comment; the comment is written in a popover there. Each unresolved comment’s text gets a dotted underline, and hovering (or focusing) it shows the thread with reply, resolve and delete. There is no comment box below the article.
- Touches:
GitInlineComments.astro(new),client.js(deleted blocks after a list item go after the list),index.d.ts,package.json,scripts/check-components.js,site/src/pages/kb/[...slug].astro,test/e2e/inline-comments.spec.js(new),test/e2e/dashboard.spec.js,test/e2e/helpers.js.
Steps
GitInlineComments.astro(propsserviceUrl,siteId,documentPath,contentSelector) renders nothing in the page flow except a line naming comments on the whole page, when there are any.- On a selection inside the content, a menu appears beside it: Comment when the reader can comment, Connect GitLab to comment (the store’s connect URL) when a session is needed, nothing when live review is unavailable. Comment opens a popover with a text field, Post and Cancel (Escape cancels). Posting creates a published comment anchored to the selection (block id, offsets, selected text, fingerprint, base sha) and reloads the store.
- Each unresolved anchored discussion wraps its text in
<span class="agic__mark" data-redline-comment="<discussion id>" tabindex="0">(dotted underline), across inline elements if needed. Marks are redrawn on every new payload and are not drawn while the timeline shows a past version. - Hovering or focusing a mark shows a card with the thread (author, time, body) and, for a connected reader, Reply (inline field), Resolve and Delete (two steps, as in T3.2). The card stays while the pointer is on it.
- Dashboard pages carry GitInlineComments instead of GitComments.
Acceptance
test/e2e/inline-comments.spec.js (dashboard on :8788):
selecting text opens a comment menu beside the selectiona comment made from the menu is underlined in the text and attributed in gitlabhovering a commented passage shows the threadreplying and deleting from the hover cardresolving from the hover card removes the underlinethe page has no comment box
npx playwright test test/e2e/inline-comments.spec.js 2>&1 | tail -1 # "6 passed"