ADR-0018: ISMS Information System
Accepted Cross-Project Territory-SpecificDate: 2026-03-24 | Last Revised: 2026-04-16 | Approved by allies: 2026-04-16
Compliance: ISO 27001
Context
The script-development organization operates under ISO 27001 certification. Compliance governance — policies, controls, procedures, review schedules, and evidence — needs a centralized system. The industry default is wiki-based tools like Confluence, but these function as document graveyards: information enters but is neither programmatically queryable nor actionable. No one knows when a control review is due without manually checking.
The team consists of 10 people:
- 4 ISMS workers who actively maintain policies, controls, reviews, and evidence. One of the four is not a programmer.
- 6 colleagues who need read access to policies only.
Requirements:
- Policies must be version-controlled with full change history (A.5.33 — protection of records)
- Control review schedules must integrate with Google Calendar
- Evidence (images, PDFs) must be stored and linked to controls — evidence is sensitive and must be auth-gated
- The non-programmer must be able to interact with the system
- Policy-only colleagues must have a clean, browsable interface limited to policies
- Notifications for policy updates, discussions, and overdue reviews
- Discussion/comments on controls and policies
Architecture Pivot
An initial design based on VitePress + Cloudflare Workers + local MCP server was evaluated and rejected during deliberation. As notification routing, discussion threads, user preferences, and evidence auth were added, the architecture became a patchwork of loosely connected components — straying from the team's established workflow. A Laravel + Vue application consolidates all of these into a single, familiar stack.
Decision
A Laravel 12 + Vue 3 application with reference documents as markdown files in the repo and operational data in PostgreSQL. Deployed on Fly.io. Evidence stored in a private R2 bucket, accessed through the Laravel backend.
The Hybrid: Files + Database
The core architectural insight is that ISMS data splits cleanly into two categories: reference documents (rarely change, need git history, must be agent-searchable) and operational data (changes frequently, needs relationships, needs queries).
| In Files (repo) | In Database (PostgreSQL) |
|---|---|
| Policies (markdown) | Control operational state (owner, status, next review) |
| Procedures (markdown) | Reviews + review history |
| Control descriptions + implementation (markdown) | Evidence records (metadata + R2 keys) |
| User accounts + roles + notification preferences | |
| Discussion comments | |
| Google Calendar sync state | |
| Notification log |
Why reference documents as files:
- Policies, procedures, and control descriptions are versioned documents — git provides immutable change history, satisfying A.5.33 (protection of records)
- These documents need to be searchable by agents — grep, read, and diff work natively on markdown files
- They change rarely — a database row is the wrong abstraction for a document that updates quarterly
- The Laravel app reads files from disk and renders them in the Vue frontend — the file is the source of truth, the app is the presentation layer
Why operational data in the database:
- Reviews, evidence, comments, and preferences change frequently, need relationships, and benefit from queries
- Calendar sync, notification routing, and user preferences require state management that files handle poorly
- Discussion threads are relational data — not documents
The control split: A control lives in two places. The markdown file holds the what and how (description, implementation details, applicable territories). The database holds the who and when (owner, status, review dates). The app merges both when rendering a control page: markdown for the body, database for the operational sidebar.
Application Structure
The repo lives at backtocode/isms and is added as a submodule to the war room at territories/isms.
isms/
app/ # Laravel application
Actions/ # final readonly action classes
DTOs/
Models/
Services/
MarkdownService.php # Reads + parses markdown files with frontmatter
Http/
Controllers/
Requests/
Notifications/ # Policy update, review due, discussion reply
Console/
Commands/
SyncCalendarCommand.php # Scheduled: sync reviews → Google Calendar
NotifyOverdueReviewsCommand.php # Scheduled: flag overdue reviews
ValidateFileDatabaseSyncCommand.php # CI: verify file ↔ DB consistency
resources/
js/ # Vue 3 frontend
Pages/
Policies/ # Renders markdown policies
Controls/ # Merged view: markdown body + DB sidebar
Dashboard/ # Overview, upcoming reviews, recent activity
policies/ # Markdown policy files (source of truth)
information-security-policy.md
access-control-policy.md
acceptable-use-policy.md
...
controls/ # Markdown control files (description + implementation)
A.5.12.md # One file per control
A.5.13.md
A.5.33.md
A.8.15.md
...
procedures/ # Markdown procedure files
incident-response.md
risk-assessment.md
...
database/
migrations/
tests/Control Markdown Format
Each control has a markdown file in resources/controls/. The file holds the reference content (description, implementation), while the database holds operational state.
# resources/controls/A.8.15.md
---
id: A.8.15
title: Logging
category: A.8 Technology Controls
---
## Description
User activities, exceptions, faults and information security events
shall be recorded in audit logs, retained, protected and analysed.
## Implementation
Per-entity audit tables with SHA-256 hash chains for tamper detection.
Explicit action-level logging — no model observers. Point-in-time user
snapshots capture actor identity at the moment of the action.
See ADR-0001 for full design. Deployed for Tier 1 entities (Issue,
User, AppSetting, GithubConnection, TimeLog) via PRs #321, #357-360,
#373. Tier 2 (10 remaining entities) scoped but not started.
## Applicable Territories
- kendoThe id in frontmatter links to the control_id column in the database. The app's MarkdownService reads and parses these files. The Vue frontend renders a merged view: markdown body for the content area, database fields for the operational sidebar (owner, status, next review, evidence list, comments).
Database Schema (Key Tables)
-- Controls: operational state only — description and implementation live in markdown files
controls
id BIGINT UNSIGNED PK
control_id VARCHAR UNIQUE -- e.g. "A.8.15" — links to resources/controls/A.8.15.md
status VARCHAR -- gap | in-progress | implemented | not-applicable
owner_id BIGINT UNSIGNED FK -- users table
review_cycle_days INT -- e.g. 180 for 6 months
next_review_at DATE
created_at TIMESTAMP
updated_at TIMESTAMP
-- Reviews: dated review records per control
reviews
id BIGINT UNSIGNED PK
control_id BIGINT UNSIGNED FK
reviewer_id BIGINT UNSIGNED FK -- users table
outcome VARCHAR -- satisfactory | action-required | escalated
findings TEXT
actions TEXT NULLABLE
reviewed_at DATE
next_review_at DATE -- Updates control's next_review_at
created_at TIMESTAMP
updated_at TIMESTAMP
-- Evidence: metadata for files stored in R2
evidence
id BIGINT UNSIGNED PK
control_id BIGINT UNSIGNED FK
uploaded_by_id BIGINT UNSIGNED FK
label VARCHAR
r2_key VARCHAR -- e.g. "A.8.15/2026-03-15-log-sample.png"
mime_type VARCHAR
file_size INT UNSIGNED
created_at TIMESTAMP
-- Comments: threaded discussions on controls and policies
comments
id BIGINT UNSIGNED PK
commentable_type VARCHAR -- "control" | "policy"
commentable_id VARCHAR -- control FK or policy slug
user_id BIGINT UNSIGNED FK
parent_id BIGINT UNSIGNED FK NULLABLE -- for threading
body TEXT
created_at TIMESTAMP
updated_at TIMESTAMP
-- Users
users
id BIGINT UNSIGNED PK
name VARCHAR
email VARCHAR UNIQUE
role VARCHAR -- isms-worker | colleague
notification_preferences JSON -- per-event channel preferences
created_at TIMESTAMP
updated_at TIMESTAMPAccess Model
| Role | Can See | Can Do |
|---|---|---|
| ISMS worker (4 people) | Everything | Full CRUD on controls, reviews, evidence, comments. Manage policies (via git). |
| Colleague (6 people) | Policies + procedures only | Read policies, comment on policies |
Laravel middleware enforces role-based access. No Cloudflare Access layer needed — the app handles auth directly.
Evidence Storage
Evidence is sensitive and auth-gated. No public URLs.
Evidence files are stored in a private Cloudflare R2 bucket. The Laravel backend handles both uploads and downloads — R2 credentials live in the server environment (Fly.io secrets), never on local machines.
isms-evidence/ # Private R2 bucket
{control-id}/
{date}-{description}.{ext}Upload flow: Vue form → Laravel controller → validates + stores in R2 → creates evidence record. Download flow: Vue requests evidence → Laravel controller checks auth → streams from R2.
The S3-compatible SDK (league/flysystem-aws-s3-v3) connects Laravel to R2.
Google Calendar Sync
A Laravel scheduled command (SyncCalendarCommand) that:
- Reads all controls with
next_review_atset - Compares against existing events in a shared Google Calendar (matched by control-ID-based event identifier)
- Creates, updates, or removes calendar events
- Each event includes: control ID, title, link to the control page in the app
- The control owner receives a calendar invite
Runs daily via Laravel's scheduler on Fly.io. Uses the Google Calendar API via a service account.
Notifications
Laravel notifications with per-user channel preferences stored in users.notification_preferences:
{
"policy_updated": ["email"],
"review_due": ["email", "google_chat"],
"review_completed": ["google_chat"],
"review_overdue": ["email", "google_chat"],
"discussion_reply": ["google_chat"]
}| Event | Default Audience | Available Channels |
|---|---|---|
| Policy published/updated | All 10 colleagues | Email, Google Chat |
| Control review due (7 days before) | Control owner | Email, Google Chat |
| Control review overdue | ISMS team (4) — escalation | Email + Google Chat (forced both) |
| Review completed | ISMS team (4) | Email, Google Chat |
| Discussion reply / mention | Mentioned user(s) | Email, Google Chat |
Google Chat notifications via incoming webhook. Email via Laravel's mail system.
Markdown Rendering
A MarkdownService reads and parses markdown files with YAML frontmatter. Used for both policies and control descriptions.
Policies (resources/policies/) carry minimal frontmatter:
---
title: Information Security Policy
version: "1.2"
effective_date: 2026-01-15
owner: jane@example.com
review_cycle: 12 months
---Controls (resources/controls/) link to the database via id:
---
id: A.8.15
title: Logging
category: A.8 Technology Controls
---The Vue frontend renders policies as standalone pages. Controls render as a merged view: markdown body (description + implementation) in the content area, database fields (owner, status, next review, evidence, comments) in the operational sidebar.
Version history comes from git — the app can shell out to git log for a specific file or use a pre-built changelog generated at deploy time.
File ↔ Database Sync Validation
Since controls live in two places (markdown file + database row), consistency must be enforced. An artisan command (ValidateFileDatabaseSyncCommand) checks:
- Every
control_idin the database has a matching markdown file - Every control markdown file has a matching database record
- The
idin frontmatter matches the filename convention
Runs as a CI check and can be invoked manually. Failures block deployment.
Statement of Applicability
The SoA is generated dynamically by merging the controls table (operational state) with control markdown files (description). A dedicated page in the Vue app lists all controls with their status, owner, last review date, next review date, and description summary. Filterable and exportable for auditors.
Options Considered
| Option | Verdict | Reason |
|---|---|---|
| Confluence / wiki-based | Rejected | Document graveyard. Not programmatically queryable. No agent access. No version control for compliance. |
| Pure markdown + VitePress (no server) | Rejected | Loses structured queries. Can't handle discussions, notifications, user preferences, or evidence auth. |
| Markdown + VitePress + Cloudflare Workers + MCP server | Rejected | Patchwork architecture — multiple loosely coupled components for problems a single app solves natively. Strays from team's established Laravel + Vue workflow. |
| Git LFS for evidence | Rejected | Adds friction for non-programmer. Team already uses R2/S3 extensively. |
| All data in database (including policies and control descriptions) | Rejected | Reference documents are versioned, rarely change, and need agent searchability — git provides immutable history (A.5.33) better than a database. |
| Laravel + Vue app with reference documents as markdown files | Accepted | Single familiar stack. Auth, notifications, evidence, discussions, calendar — all handled by the framework. Policies, procedures, and control descriptions stay as files for version control and agent access. Operational data (reviews, evidence, preferences) lives in the database where it belongs. |
Consequences
Positive
- Single stack — Laravel + Vue is the team's established workflow (kendo, brick-inventory). No new tooling to learn.
- Reference documents in git — policies, procedures, and control descriptions have immutable change history, satisfying A.5.33. Agents can grep and read files natively.
- Auth built-in — Laravel middleware handles role-based access. No external auth layer needed.
- Notifications built-in — Laravel notifications with per-user channel preferences. No external notification routing.
- Discussions built-in — threaded comments in the database. No external discussion system.
- Evidence auth built-in — Laravel streams from R2 after checking auth. No separate Worker needed.
- Calendar sync — a single scheduled command. No external sync infrastructure.
- Non-programmer has a UI — no git or CLI required for most operations. Claude Code is a bonus interface, not the primary one.
Negative
- More infrastructure than a static site (server, database, deployment pipeline)
- Reference document changes (policies, control descriptions) require git commits — colleagues can't edit them in the UI (by design — these are controlled documents)
- Control data lives in two places (file + database) — sync validation required
- Application maintenance burden (security updates, framework upgrades)
Risks
- File / database drift — If a markdown file is renamed or deleted without updating the database (or vice versa), the app shows broken controls. Mitigation:
ValidateFileDatabaseSyncCommandruns in CI and blocks deployment on mismatch. - Calendar sync drift — If the scheduled command fails, calendar events become stale. Mitigation: daily schedule + monitoring via Fly.io health checks.
- R2 orphaned files — Evidence deleted from database but not from bucket. Mitigation: the
DeleteEvidenceActiondeletes from both R2 and database in a transaction. Periodic reconciliation as safety net.
Enforcement
| What | Mechanism | Scope |
|---|---|---|
| Role-based access | Laravel middleware + architecture test | All routes |
| File ↔ DB sync | ValidateFileDatabaseSyncCommand in CI — blocks deploy on mismatch | resources/policies/*.md, resources/controls/*.md |
| Evidence cleanup | DeleteEvidenceAction handles R2 + DB atomically | Evidence CRUD |
| Calendar sync health | Fly.io scheduled command monitoring | SyncCalendarCommand |
| Action pattern compliance | Pest architecture tests | app/Actions/ |
Implementation
| Territory | State | Notes |
|---|---|---|
| isms (backtocode) | Not Started | Repo to be created. Laravel 12 + Vue 3 + PostgreSQL. Deploy on Fly.io. First policy: information security policy. First control: A.8.15 (evidence from ADR-0001). |