Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
096dcf3
test(data): specify schema migration state boundary
seonghobae Aug 14, 2026
d2b5cde
test(data): run schema migration state contract in CI
seonghobae Aug 14, 2026
7070a29
feat(data): add fail-closed schema migration ledger
seonghobae Aug 14, 2026
88cb945
feat(data): enforce migration-state guard at startup
seonghobae Aug 14, 2026
cf0d0f0
docs(data): record schema migration ledger boundary
seonghobae Aug 14, 2026
ec8b74e
docs(changelog): record schema migration guard
seonghobae Aug 14, 2026
5c50012
test(data): cover migration guard failure branches
seonghobae Aug 14, 2026
3eb58ba
test(data): reject legacy recreation on canonical schema
seonghobae Aug 14, 2026
6cbded1
fix(data): inspect schema before legacy bootstrap
seonghobae Aug 14, 2026
2832fba
fix(data): fail before legacy schema recreation
seonghobae Aug 14, 2026
a8901a6
test(data): cover pre-bootstrap schema inspection
seonghobae Aug 14, 2026
fa2581b
docs(data): record pre-bootstrap fail-closed boundary
seonghobae Aug 14, 2026
782c8ca
test(data): reject impossible migration rollback history
seonghobae Aug 14, 2026
9682288
fix(data): reject backward migration ledger history
seonghobae Aug 14, 2026
6386009
test(data): cover monotonic migration ledger history
seonghobae Aug 14, 2026
c1752f2
docs(data): document monotonic migration history
seonghobae Aug 14, 2026
ba5d119
docs(changelog): record migration history guard
seonghobae Aug 14, 2026
d36200c
fix(data): make migration state map prototype-free
seonghobae Aug 14, 2026
b7e4a2e
merge(data): reconcile migration ledger with protected develop
seonghobae Aug 16, 2026
b98c3f5
merge(data): reconcile protected XML import baseline
seonghobae Aug 16, 2026
85ee768
merge(data): reconcile schema migration ledger with current develop
seonghobae Aug 16, 2026
74dd422
test(data): expose swallowed legacy column migration failures
seonghobae Aug 17, 2026
5624541
test(data): register legacy column migration regression
seonghobae Aug 17, 2026
345202f
fix(data): fail closed on legacy column migration errors
seonghobae Aug 17, 2026
26932ce
fix(data): use catalog-first compatibility migrations
seonghobae Aug 17, 2026
525e995
docs(data): record fail-closed compatibility DDL
seonghobae Aug 17, 2026
44bd114
docs(data): document catalog-first legacy column upgrades
seonghobae Aug 17, 2026
ad4166b
merge(develop): reconcile migration ledger with OpenCode config
seonghobae Aug 17, 2026
2a4e118
merge(develop): reconcile schema migration ledger with adaptive attri…
seonghobae Aug 19, 2026
2af6d29
fix(stack): reconcile schema migration ledger with protected develop
seonghobae Aug 20, 2026
1bca59e
test(data): reject incompatible legacy columns
seonghobae Aug 20, 2026
41925c0
fix(data): validate legacy column definitions
seonghobae Aug 20, 2026
1902554
test(ci): require database startup coverage
seonghobae Aug 20, 2026
9c1dcba
fix(ci): cover database startup control flow
seonghobae Aug 20, 2026
7751daf
merge(develop): preserve Playwright 1.62.1 in schema ledger slice
seonghobae Aug 20, 2026
1087eb2
test(schema): require streamed migration metadata
seonghobae Aug 21, 2026
4eeb8ad
test(schema): register streaming regressions
seonghobae Aug 21, 2026
1c3a76a
fix(schema): stream migration catalog evidence
seonghobae Aug 21, 2026
f9410c3
test(data): require atomic initial schema bootstrap
seonghobae Aug 21, 2026
c50e7d1
test(data): cover atomic bootstrap contract
seonghobae Aug 21, 2026
8c5d023
fix(data): make initial schema bootstrap atomic
seonghobae Aug 21, 2026
348b105
fix(data): wire atomic schema bootstrap
seonghobae Aug 21, 2026
84d0177
docs(data): document atomic bootstrap recovery
seonghobae Aug 21, 2026
f5e82a4
test(data): reject malformed migration ledger schema
seonghobae Aug 21, 2026
864887c
test(data): register migration ledger schema regression
seonghobae Aug 21, 2026
502bc13
fix(data): validate migration ledger schema
seonghobae Aug 21, 2026
0ab9a74
test(data): model validated ledger metadata in streaming fixture
seonghobae Aug 21, 2026
f2aeb67
test(data): align ledger fixtures with enforced schema
seonghobae Aug 21, 2026
3a25bc1
test(data): reject ledger timestamp default drift
seonghobae Aug 21, 2026
35a0cfe
fix(data): enforce ledger timestamp default contract
seonghobae Aug 21, 2026
aaa3566
test(data): align streaming ledger schema fixture
seonghobae Aug 21, 2026
f63b55c
test(data): reproduce rolling-start migration lock
seonghobae Aug 21, 2026
033d960
test(data): register rolling-start lock regression
seonghobae Aug 21, 2026
c51d11d
fix(data): avoid no-op startup write locks
seonghobae Aug 21, 2026
db0df09
fix(data): preserve concurrent ledger idempotence
seonghobae Aug 21, 2026
5777834
test(data): fix persisted-ledger streaming expectation
seonghobae Aug 21, 2026
b15cbbe
test(data): reproduce skipped additive bootstrap
seonghobae Aug 21, 2026
6bab0a5
fix(data): preserve additive schema bootstrap
seonghobae Aug 21, 2026
37e27e7
test(data): prove migrated startup skips ledger DDL
seonghobae Aug 27, 2026
9f2e681
fix(data): avoid redundant migration-ledger DDL
seonghobae Aug 27, 2026
b7ef5c5
fix(data): reject unknown schema objects
seonghobae Aug 28, 2026
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added workflow ownership regression coverage so central review
workflows stay inherited from `ContextualWisdomLab/.github`, not copied
into this repository.
- Added an append-only `schema_migrations` ledger and startup migration-state
guard that records the complete legacy/canonical table-name generation,
rejects unknown/corrupt history and backward generation rollback, and fails
closed if a rename cutover leaves the database partial or mixed.
- Rejected unrecognized application tables during schema-generation
classification so an otherwise complete schema cannot bypass the migration
guard with an unverified object.

### Security

Expand Down Expand Up @@ -61,6 +68,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
mode, delegating provider/model/topology policy to the shared service without
weakening ScopeWeave's authenticated, fail-closed transport or response
boundary controls.
- Legacy `token_version`, `archived`, and `methodology` compatibility migrations
now inspect SQLite catalog metadata before `ALTER TABLE` and propagate real
DDL failures instead of treating every exception as an already-applied column.
- Accepted XML whitespace before exact Microsoft Project element delimiters
while preserving the linear, regex-free import scanner and rejecting
attributes, longer names, non-XML whitespace, nested unmatched blocks, and
Expand Down
161 changes: 161 additions & 0 deletions docs/doctoring/schema-migration-ledger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Schema migration ledger and fail-closed generation guard

## Decision

Issue #433 requires the existing single-word SQLite object names to move to the
repository's two-or-more-word `snake_case` contract without serving a database
that is half old-schema and half canonical-schema. The first bounded
expand/verify slice introduces an append-only `schema_migrations` ledger and a
framework-independent generation classifier before any destructive rename is
attempted.

The guard recognizes exactly two complete generations for the ten tables in the
rename plan:

- legacy: `users`, `orgs`, `memberships`, `projects`, `invites`, `webhooks`,
`baselines`, `comments`, `sprints`, `attachments`;
- canonical: `user_accounts`, `organization_records`,
`organization_memberships`, `project_records`, `invitation_records`,
`webhook_endpoints`, `project_baselines`, `project_comments`,
`project_sprints`, `project_attachments`.

The `schema_migrations` table is migration metadata and is excluded from the
generation catalog. Any other unrecognized table, missing table, old/new
mixture, or duplicated generation is an invalid startup state and raises
`SchemaMigrationStateError` before request handling begins. This prevents an
otherwise complete generation from being trusted alongside an object that the
query layer has not verified. The existing non-renamed tables
(`webhook_deliveries`, `audit_log`, `api_tokens`, `project_revisions`,
`share_tokens`, and `project_seen`) are explicitly allowlisted as stable
application objects.

## Pre-bootstrap boundary

The database catalog is inspected **before** the legacy `CREATE TABLE IF NOT
EXISTS` and additive `ALTER TABLE` statements run. Only a genuinely empty
database may initialize the legacy schema from scratch. An existing database
must already be one complete known generation; mixed, incomplete, ledger-only,
or otherwise ambiguous states fail closed before legacy bootstrap can mutate
them.

The complete legacy `CREATE TABLE`/`CREATE INDEX` bootstrap is executed inside
one explicit SQLite transaction. If a first deployment is terminated while that
transaction is open, SQLite recovery returns the catalog to its pre-transaction
state; if a statement fails synchronously, ScopeWeave explicitly rolls the
transaction back before rethrowing the causal error. This prevents a brand-new
database from being stranded as an otherwise unrecoverable partial legacy
generation. A partial generation that already exists before startup remains
fail-closed and requires verified recovery rather than heuristic completion.

The historical compatibility columns `users.token_version`,
`projects.archived`, and `projects.methodology` are also catalog-driven. Startup
queries `PRAGMA table_info` and runs `ALTER TABLE` only when the exact column is
absent. This replaces the former blanket `try/catch` pattern that treated every
SQLite exception as "already there". With expected idempotence removed from the
exception path, read-only, disk-I/O, locking, corruption, or other genuine DDL
failures propagate and stop startup instead of leaving a partially upgraded
schema available to request handling.

A complete canonical generation is deliberately identified and recorded, but
this application version still uses legacy table names in its query layer.
Therefore startup records/verifies `canonical_schema_v2` and then fails with a
stable "canonical schema generation is not yet supported by this application
version" error. It does **not** recreate legacy tables over the canonical
schema. Serving a canonical database becomes valid only in the later issue #433
slice that migrates the application query/data-access layer as part of the same
reviewed cutover.

## Ledger contract

`schema_migrations` contains a stable `migration_key`, a low-cardinality
`state_code`, and an application timestamp. Repeated startup is idempotent via
`INSERT OR IGNORE`. Every persisted row must be one of the known exact
migration-key/state pairs before the current generation is recorded. Unknown
keys or corrupted state codes fail closed rather than being treated as harmless
history.

The ledger is monotonic across the naming migration. A canonical database may
retain the earlier `legacy_schema_v1` record and append `canonical_schema_v2`.
The reverse is forbidden: if the schema catalog is legacy while the ledger says
`canonical_schema_v2` has ever been reached, startup fails because that state is
consistent with an unsupported reverse migration, stale restore, or incomplete
recovery. Backup restoration must restore schema and migration history to one
coherent point rather than silently moving the schema backward under newer
ledger evidence.

After insertion, the persisted state is read back and compared with the fresh
schema-catalog classification so a corrupted current-generation record cannot
silently bless the database.

This slice deliberately does **not** rename application tables, create legacy
compatibility views, or claim PostgreSQL adapter readiness. Those operations
remain subsequent issue #433 work and must use the ledger/guard as the
precondition for an atomic expand/verify/contract cutover.

## Failure and recovery boundary

A partially renamed database is not automatically repaired on startup. Serving
mixed names would make query routing ambiguous and could split reads and writes
between generations. The process therefore fails closed. Operators must restore
a verified backup or complete the reviewed migration before restarting.

The later rename executor must run with foreign keys enabled, modern SQLite
rename propagation semantics, pre/post `PRAGMA integrity_check` and
`PRAGMA foreign_key_check`, deterministic interruption tests, restart evidence,
and a restore rehearsal. Reverse renames are not a substitute for backup
recovery, and a restore that moves from canonical history back to legacy schema
must restore the ledger from the same verified recovery point.

## Verification

`tests/unit/schema-migration-state.test.mjs` covers:

- idempotent legacy ledger creation;
- a distinct canonical-generation ledger record;
- preservation of valid legacy history when the canonical record is appended;
- rejection of unknown or corrupted migration-ledger identities/states;
- rejection of an otherwise complete generation paired with an unknown
application table;
- rejection of a canonical ledger record paired with a legacy schema generation;
- fail-closed mixed-generation detection;
- incomplete schema rejection;
- the complete ten-object legacy and canonical inventories;
- pre-bootstrap classification of pristine, legacy, canonical, and invalid
databases;
- atomic legacy-bootstrap commit behavior, rollback after a real mid-script
SQLite DDL failure, input validation, and preservation of the causal error even
if best-effort rollback itself fails; and
- a real `server/db.mjs` subprocess regression proving canonical startup fails
for the truthful query-layer reason without recreating any legacy table.

`tests/unit/legacy-column-migration.test.mjs` additionally proves that the three
historical additive columns are created idempotently from SQLite catalog
evidence, preserve their production defaults, reject incomplete adapters, and
propagate a real database error rather than converting it into false idempotent
success.

The production module is registered in the Istanbul coverage command. The API
and existing server tests exercise normal startup integration through
`server/db.mjs`, while the canonical-database subprocess exercises the opposite
fail-closed boundary against a persisted SQLite file.

## Rollback

Before any table rename ships, rollback of this slice is non-destructive: revert
the startup guard and module. The extra `schema_migrations` table is inert data
and may remain in an existing database. Once a future rename migration is
released, rollback must follow that migration's backup/restore runbook instead
of deleting ledger history.

## References

International Organization for Standardization. (2023). *Information
technology—Database languages SQL—Part 1: Framework (SQL/Framework)*
(ISO/IEC Standard No. 9075-1:2023).
https://www.iso.org/standard/76583.html

SQLite Consortium. (2026). *ALTER TABLE*.
https://sqlite.org/lang_altertable.html

SQLite Consortium. (2026). *PRAGMA statements*.
https://sqlite.org/pragma.html
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
"coverage": "npm run test:coverage",
"server": "node server/server.mjs",
"test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs",
"test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs",
"test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases",
"test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api",
"test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/schema-migration-state.test.mjs && node tests/unit/schema-migration-concurrent-startup.test.mjs && node tests/unit/schema-migration-streaming.test.mjs && node tests/unit/schema-migration-ledger-contract.test.mjs && node tests/unit/legacy-column-migration.test.mjs && node tests/unit/toast-accessibility.test.mjs",
"test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/db.mjs --include=server/orchestrator.mjs --include=server/schema_migration.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases",
"test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/schema-migration-state.test.mjs && node tests/unit/schema-migration-concurrent-startup.test.mjs && node tests/unit/schema-migration-streaming.test.mjs && node tests/unit/schema-migration-ledger-contract.test.mjs && node tests/unit/legacy-column-migration.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js",
Expand Down
32 changes: 27 additions & 5 deletions server/db.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,33 @@
import { DatabaseSync } from 'node:sqlite';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import {
ensureLegacyCompatibilityColumns,
ensureSchemaMigrationState,
inspectSchemaBootstrapState,
runAtomicLegacySchemaBootstrap,
SchemaMigrationStateError,
} from './schema_migration.mjs';

const __dirname = dirname(fileURLToPath(import.meta.url));
const dbPath = process.env.SCOPEWEAVE_DB || join(__dirname, '..', 'data.db');
export const db = new DatabaseSync(dbPath);
db.exec("PRAGMA journal_mode = WAL");
db.exec("PRAGMA foreign_keys = ON");

db.exec(`
// Inspect the existing generation before legacy CREATE/ALTER statements can
// mutate it. The current application query layer still targets legacy names, so
// a complete canonical database is identified in the ledger and then rejected
// until the query cutover ships; mixed/incomplete databases fail even earlier.
const initialSchemaState = inspectSchemaBootstrapState(db);
if (initialSchemaState === 'canonical_ready') {
ensureSchemaMigrationState(db);
throw new SchemaMigrationStateError(
'canonical schema generation is not yet supported by this application version',
);
}
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.

runAtomicLegacySchemaBootstrap(db, `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
Expand Down Expand Up @@ -172,10 +191,13 @@ CREATE INDEX IF NOT EXISTS idx_projects_org ON projects(org_id);
CREATE INDEX IF NOT EXISTS idx_invites_token ON invites(token);
`);

// Migration for pre-existing DBs: add token_version if missing (idempotent).
try { db.exec('ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0'); } catch { /* already there */ }
try { db.exec('ALTER TABLE projects ADD COLUMN archived INTEGER NOT NULL DEFAULT 0'); } catch { /* already there */ }
try { db.exec("ALTER TABLE projects ADD COLUMN methodology TEXT NOT NULL DEFAULT 'waterfall'"); } catch { /* already there */ }
// Compatibility migrations are catalog-driven so expected idempotence never
// relies on swallowing unrelated SQLite failures as "already there".
ensureLegacyCompatibilityColumns(db);

// Issue #433 migration guard: record the complete naming generation and refuse
// to serve a database left in a partial old/new table-name cutover.
ensureSchemaMigrationState(db);

// node:sqlite returns lastInsertRowid as number|bigint; normalize to Number.
export const rowid = (r) => Number(r.lastInsertRowid);
Loading
Loading