From cab7e585c59807e8f75d0d384cd8c80f24f4fa94 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 14:50:44 -0600 Subject: [PATCH 1/8] docs(2993): design + implementation plan for safe draft_admin_integrity re-enable --- ...-02-draft-admin-integrity-safe-reenable.md | 195 ++++++++++++++++++ ...ft-admin-integrity-safe-reenable-design.md | 160 ++++++++++++++ 2 files changed, 355 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-02-draft-admin-integrity-safe-reenable.md create mode 100644 docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md diff --git a/docs/superpowers/plans/2026-11-02-draft-admin-integrity-safe-reenable.md b/docs/superpowers/plans/2026-11-02-draft-admin-integrity-safe-reenable.md new file mode 100644 index 000000000..9b62cffe8 --- /dev/null +++ b/docs/superpowers/plans/2026-11-02-draft-admin-integrity-safe-reenable.md @@ -0,0 +1,195 @@ +# draft_admin_integrity Safe Re-enable (Prove, Keep OFF) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `cds.fiori.draft_admin_integrity` provably safe to re-enable later — by making the cascade FK deployable and adding the orphan gate test — without turning the flag on now. + +**Architecture:** The build emits 46 `AdminService.*_drafts.hdbtable` files carrying the `DraftAdministrativeData_DraftUUID` FK column but no `ON DELETE CASCADE` clause (the #2909 root cause). A build-time patch (extracted into a committed `.cjs`, wired into the MTA `before-all` after `cds build`) appends the cascade FK to each generated draft `.hdbtable` in the prod `gen/db` tree. A flag-agnostic Vitest gate proves Mission draftEdit→discard leaves zero child draft rows on SQLite. A runbook documents the deploy-then-flip order. The `package.json` flag stays `false`. + +**Tech Stack:** SAP CAP (`@sap/cds` 10.2.2, Node), SAP HANA HDI (`.hdbtable`), MTA (`.deploy/mta.yaml`), Vitest, SQLite (unit). + +**Spec:** `docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md` + +## Global Constraints + +- `cds.fiori.draft_admin_integrity` stays `false` in committed `package.json` (both the base `cds.fiori` block at line ~461 and the `[development]` block at line ~486). No task flips it. +- No deploy to any environment is performed by this plan. +- Branch is `worktree-draft-integrity-constraints-2993`, based on `origin/DEV`. PR targets `DEV`, never `main`. +- `npm test` (in-memory SQLite) must stay green; the gate test is additive. +- Constraint patch applies to the **prod `gen/db` tree only** — QA's db build excludes `AdminService` and emits 0 draft tables. +- Cascade FK form, per table: `FOREIGN KEY (DraftAdministrativeData_DraftUUID) REFERENCES "DRAFT_DRAFTADMINISTRATIVEDATA"("DRAFTUUID") ON DELETE CASCADE` — authored so HANA deploys it `NOT VALIDATED` (skips existing-data check; no pre-clean needed). +- Draft table list is derived programmatically from the generated `*_drafts.hdbtable` files — never hand-maintained (must not drift from the preflight's live `SYS.TABLE_COLUMNS` count). + +## Review Focus + +- **Zero draft tables found** (glob matches nothing because build layout changed): patch must `die(1)` with a clear message, not silently patch nothing and let a future flip ship an un-cascaded DB → covered in Task 1. +- **Already-patched `.hdbtable`** (re-run / idempotency): appending the FK a second time must be a no-op, not a duplicate-constraint DDL error → covered in Task 1. +- **A draft table that lacks the FK column** (not all `*_drafts` necessarily carry it): patch must skip tables without `DraftAdministrativeData_DraftUUID`, not emit a FK referencing a missing column → covered in Task 1. +- **Discard of a Mission with NO child items** (empty composition): gate test must still pass (zero in, zero orphaned), not assume children exist → covered in Task 2. +- **Flag-on behavior on SQLite** (future flip): gate test asserts via the `.drafts` accessor so it is correct whether deletion came from app-level reconcile or DB cascade → covered in Task 2. + +--- + +### Task 1: Build-time cascade-FK patch for generated draft `.hdbtable` files + +**Files:** +- Create: `scripts/patch-draft-cascade-constraints.cjs` +- Create: `test/unit/patch-draft-cascade-constraints.test.js` +- Modify: `.deploy/mta.yaml` (the `before-all` builder, after the `cds build --production` line ~52, in the same shell sequence as the existing `.hdiconfig` patch line ~53) + +**Interfaces:** +- Consumes: generated `gen/db/src/gen/AdminService.*_drafts.hdbtable` files (produced by `npx cds build --production`, already run earlier in `before-all`). +- Produces: `patchDraftCascadeConstraints(genDir) -> { patched: string[], skipped: string[] }` exported from the `.cjs`; appends the cascade FK to each draft `.hdbtable` under `genDir` that has the FK column and isn't already patched. Throws if `genDir` contains zero `*_drafts.hdbtable` files. + +- [ ] **Step 1: Write the failing unit test** + +In `test/unit/patch-draft-cascade-constraints.test.js`, using Vitest + a tmp dir fixture (no CAP boot): +```js +// seed tmp genDir with 2 fixture .hdbtable files: +// AdminService.Missions_drafts.hdbtable — has DraftAdministrativeData_DraftUUID column +// AdminService.NoFk_drafts.hdbtable — NO such column +// and assert: +test('appends ON DELETE CASCADE FK to draft tables that carry the FK column', () => { + const r = patchDraftCascadeConstraints(tmp); + const sql = fs.readFileSync(path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), 'utf8'); + expect(sql).toMatch(/FOREIGN KEY\s*\("?DraftAdministrativeData_DraftUUID"?\)\s*REFERENCES\s+"DRAFT_DRAFTADMINISTRATIVEDATA"\("DRAFTUUID"\)\s+ON DELETE CASCADE/i); + expect(r.patched).toContain('AdminService.Missions_drafts.hdbtable'); +}); +test('skips draft tables without the FK column', () => { + patchDraftCascadeConstraints(tmp); + const sql = fs.readFileSync(path.join(tmp, 'AdminService.NoFk_drafts.hdbtable'), 'utf8'); + expect(sql).not.toMatch(/ON DELETE CASCADE/i); +}); +test('is idempotent — second run adds no duplicate FK', () => { + patchDraftCascadeConstraints(tmp); + const first = fs.readFileSync(path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), 'utf8'); + patchDraftCascadeConstraints(tmp); + expect(fs.readFileSync(path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), 'utf8')).toBe(first); +}); +test('throws when no draft tables are found', () => { + expect(() => patchDraftCascadeConstraints(emptyTmp)).toThrow(/no .*_drafts\.hdbtable/i); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run test/unit/patch-draft-cascade-constraints.test.js` +Expected: FAIL (module/function not found). + +- [ ] **Step 3: Implement `patchDraftCascadeConstraints(genDir)` in `scripts/patch-draft-cascade-constraints.cjs`** + +Enumerate `*_drafts.hdbtable` in `genDir` (fs.readdirSync); throw if none. For each file: read; skip if no `DraftAdministrativeData_DraftUUID` token or if `ON DELETE CASCADE` already present (idempotency); otherwise inject the FK clause as the last element inside the `COLUMN TABLE (...)` body (insert a `,` after the current last column/`PRIMARY KEY(...)` entry, before the closing `)`). Also export a CLI entry (`if (require.main === module)`) that reads genDir from `process.argv[2]` default `gen/db/src/gen`, calls the function, logs `patched N / skipped M`, and `process.exit(1)` on throw. + +- [ ] **Step 4: Run to verify it passes** + +Run: `npx vitest run test/unit/patch-draft-cascade-constraints.test.js` +Expected: PASS (all 4). + +- [ ] **Step 5: Wire into MTA `before-all`** + +In `.deploy/mta.yaml`, add a command after the `cds build --production` step and alongside the existing `.hdiconfig` patch (same `before-all` builder): `bash -c "cd .. && node scripts/patch-draft-cascade-constraints.cjs gen/db/src/gen"`. Prod tree only — do NOT add `gen/db-qa/...`. + +- [ ] **Step 6: Verify the patch runs against a real build (prod tree)** + +Run: `rm -rf gen && npx cds build --production && node scripts/patch-draft-cascade-constraints.cjs gen/db/src/gen && grep -l "ON DELETE CASCADE" gen/db/src/gen/AdminService.Missions_drafts.hdbtable` +Expected: prints the Missions_drafts path (cascade present); CLI logs `patched 46 / skipped 0` (or patched = live FK-bearing count). Then `rm -rf gen` (throwaway). + +- [ ] **Step 7: Commit** + +```bash +git add scripts/patch-draft-cascade-constraints.cjs test/unit/patch-draft-cascade-constraints.test.js .deploy/mta.yaml +git commit -m "feat(db): build-time ON DELETE CASCADE patch for draft tables, not yet enabled (#2993)" +``` + +--- + +### Task 2: Flag-agnostic orphan gate test (Mission draftEdit → discard) + +**Files:** +- Create: `test/admin-drafts-cascade-gate.test.js` + +**Interfaces:** +- Consumes: CAP draft HTTP actions on `AdminService` via `cds.test('serve','--project','.','--in-memory')` + `adminAuth`, exactly as `test/admin-drafts-discard-reconcile.test.js`. Child-draft rows read via the `.drafts` accessor (e.g. `cds.entities(NS).CompletionPathItems.drafts`), filtered by `DraftAdministrativeData_DraftUUID`. +- Produces: nothing consumed downstream; this is the re-enable gate. + +- [ ] **Step 1: Write the failing test** + +In `test/admin-drafts-cascade-gate.test.js`, mirroring the harness/auth/seed style of `admin-drafts-discard-reconcile.test.js`: +```js +// Seed a Mission with one CompletionPath + one CompletionPathItem (active). +// draftEdit the Mission; capture the draft's DraftAdministrativeData_DraftUUID +// from the Missions.drafts row. Then DELETE the draft (discard). +it('leaves zero child draft rows after Mission draftEdit→discard (flag-agnostic gate)', async () => { + // ... seed, draftEdit, read draftUUID ... + const { CompletionPathItems } = cds.entities(NS); + const before = await db.run(SELECT.from(CompletionPathItems.drafts) + .where({ DraftAdministrativeData_DraftUUID: draftUUID })); + expect(before.length).toBeGreaterThanOrEqual(0); // children may be 0..n + // ... discard ... + const after = await db.run(SELECT.from(CompletionPathItems.drafts) + .where({ DraftAdministrativeData_DraftUUID: draftUUID })); + expect(after).toHaveLength(0); +}); +// Empty-composition case: +it('discard of a Mission draft with no child items leaves zero orphans', async () => { /* seed Mission only, draftEdit, discard, assert 0 */ }); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run test/admin-drafts-cascade-gate.test.js` +Expected: FAIL (file new / assertions not yet satisfied, or compile error until seed written). + +- [ ] **Step 3: Implement the test body** + +Fill in seed (Users/Missions/CompletionPaths/CompletionPathItems with unique `__TEST__`/high-legacyId values to avoid autotest collisions, per `admin-drafts-discard-reconcile.test.js`), the draftEdit POST to `/admin/Missions(ID=...,IsActiveEntity=true)/AdminService.draftEdit`, read the draft row's `DraftAdministrativeData_DraftUUID`, and the discard DELETE to `/admin/Missions(ID=...,IsActiveEntity=false)`. No production code changes — this test documents current (flag-off) behavior. + +- [ ] **Step 4: Run to verify it passes** + +Run: `npx vitest run test/admin-drafts-cascade-gate.test.js` +Expected: PASS (both cases). Then `npm test` to confirm no regression. + +- [ ] **Step 5: Commit** + +```bash +git add test/admin-drafts-cascade-gate.test.js +git commit -m "test(drafts): flag-agnostic gate — Mission discard leaves zero child draft orphans (#2993)" +``` + +--- + +### Task 3: Re-enable runbook + +**Files:** +- Create: `docs/developers/operations/draft-integrity-reenable.md` +- Modify: `CLAUDE.md` (the "Optimized draft deletion" gotcha line — add a pointer to the new runbook) + +**Interfaces:** none (documentation). + +- [ ] **Step 1: Write the runbook** + +Document the exact order, encoding the spec guardrails: (1) deploy with the Task-1 patch active so constraints land `NOT VALIDATED` while the flag is still `false` (constraints inert); (2) confirm `srv/lib/draft-integrity-preflight.js` logs `verified (N/N)` on the target env (not a WARN); (3) run `scripts/cleanup-draft-orphans.cjs --dry-run` → expect 0 orphans; (4) run the Task-2 gate test green; (5) only then flip `draft_admin_integrity` to `true` in `package.json` (both blocks), full `npm run build:all` + redeploy; (6) smoke a Mission draftEdit→discard. Note: the `CDS_*` env override does NOT work on the deployed build — the baked `package.json` value is authoritative. DEV before PROD. + +- [ ] **Step 2: Add CLAUDE.md pointer** + +Append ` Re-enable runbook: docs/developers/operations/draft-integrity-reenable.md` to the existing "Optimized draft deletion" gotcha bullet. + +- [ ] **Step 3: Commit** + +```bash +git add docs/developers/operations/draft-integrity-reenable.md CLAUDE.md +git commit -m "docs(ops): runbook for safely re-enabling draft_admin_integrity (#2993)" +``` + +--- + +## Self-Review + +**Spec coverage:** Goal 1 (constraints) → Task 1. Goal 2 (gate test) → Task 2. Goal 3 (runbook) → Task 3. Non-goals (flip flag, deploy, orphan sweep) respected — no task does them. QA-out-of-scope finding → Task 1 Step 5 (prod tree only). Preflight already fixed (#2992) — not re-implemented. All covered. + +**Step scan:** Each step yields one checkable action. Task 1 bodies describe only the FK-injection algorithm (not signature-determined); Task 2 leaves the body to the implementer via the harness pattern. No "TBD"/"handle edge cases" lines. + +**Type consistency:** `patchDraftCascadeConstraints(genDir)` and its return shape used consistently across Task 1 steps and the MTA wiring. FK clause string identical in Global Constraints, Task 1 test, and Task 3. + +**Review Focus:** 5 items, each pinned to a task (empty-tables→T1, idempotency→T1, missing-FK-column→T1, empty-composition→T2, flag-on-accessor→T2). + +**Proportion:** Plan is longer than the ~2-screen spec but dominated by exact test assertions and the FK string, not program transcription — acceptable for a TDD plan with 3 tasks. diff --git a/docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md b/docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md new file mode 100644 index 000000000..22d433521 --- /dev/null +++ b/docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md @@ -0,0 +1,160 @@ +# Design: Prove `draft_admin_integrity` is safe to re-enable — keep it OFF for now (#2993) + +Date: 2026-11-02 +Author: Claude (with Tom) +Status: Draft for approval + +## Intent & why + +CAP 10.2's `cds.fiori.draft_admin_integrity: true` (#2909) was enabled in +`package.json` to get optimized DB-level cascade draft deletion. The required +`ON DELETE CASCADE` constraints on the `ADMINSERVICE_*_DRAFTS` tables were +**never emitted by the build nor deployed**, so with the flag on the runtime +deleted only the `DRAFT_DRAFTADMINISTRATIVEDATA` parent and orphaned every +child draft row — making missions un-editable in PROD (2026-11-02 outage). +#2992 disabled the flag (all profiles) and fixed the preflight; #2994 shipped a +one-shot orphan sweep. + +Tom's decision for this issue: **prove re-enabling is safe, but keep the flag +OFF.** We cannot afford another outage. This issue delivers the *artifacts and +the proof*, not the switch. + +## Goals + +1. **Author the real cascade constraints as committed, deployable artifacts** + for every `AdminService.*_drafts` table. The build already emits these tables + with the `DraftAdministrativeData_DraftUUID` FK column but no cascade clause; + the work adds `ON DELETE CASCADE` (`NOT VALIDATED`) via a build-time patch of + the generated `.hdbtable` files (see Findings + deliverable 1). They must be + deployable independently of the flag. +2. **A flag-agnostic gate test** proving `draftEdit → discard` on a Mission + leaves **zero** orphaned child draft rows. Runs in `npm test` (SQLite). + Written so it passes both with the flag off (today's app-level reconcile + path) and would pass with the flag on (DB cascade) — it is the gate that + must stay green before any future flip. +3. **A re-enable runbook** documenting the exact deploy-then-flip order so the + issue's steps 2–4 become an executable checklist. + +## Non-goals + +- Flipping `cds.fiori.draft_admin_integrity` to `true`. Stays `false` in the + committed `package.json` (base + `[development]`). +- Deploying anything to any environment. +- The orphan *sweep* — already delivered in #2994 (`cleanup-draft-orphans.cjs`). +- Changing the preflight — already fixed in #2992 (discovers real + `%_DRAFTS` tables from `SYS.TABLE_COLUMNS`). + +## Anti-goals (would make this a failure even if it "works") + +- Any change that could cause the DB-cascade delete path to become active on a + live environment (i.e. the flag going on, directly or via a build artifact + that behaves like enabling it). +- Committed artifacts that `cf deploy` would apply as `VALIDATED` (would block + deploy on existing orphan data) or that `die` the deploy if a referenced + draft table does not yet exist on the target. + +## Findings (verified this session) + +- **The build emits the draft tables WITH the FK column, but WITHOUT the + cascade clause.** `cds build --production` emits **46** + `gen/db/src/gen/AdminService._drafts.hdbtable` artifacts (tracked HDI + `.hdbtable` files). Each carries `DraftAdministrativeData_DraftUUID + NVARCHAR(36) NOT NULL`, but **no** `FOREIGN KEY ... ON DELETE CASCADE` clause + — verified by `cat`/`grep` on the generated `AdminService.Missions_drafts.hdbtable`. + The flag (`draft_admin_integrity` true vs false) does **not** change this + output. This is the #2909 root cause: the physical draft tables and their FK + column exist as build artifacts; only the cascade FK clause is missing, so the + DB never cascades child-row deletion. (An earlier probe reported "zero draft + artifacts" — that was a bad `fd` glob, not a real absence; corrected here.) +- **`.hdiconfig` does not register the `hdbconstraint` plugin.** `db/src/.hdiconfig` + registers hdbindex/hdbmigrationtable/hdbprocedure/hdbsequence/hdbsynonym/ + hdbview/hdbrole — **no** `com.sap.hana.di.constraint`. So a committed + `db/src/*.hdbconstraint` would be **silently ignored** by HDI. Precedent for + fixing this: the MTA `before-all` already patches the generated `.hdiconfig` + at build time (today it injects the `hdbprocedure` plugin into both + `gen/db/src/gen/.hdiconfig` and `gen/db-qa/src/gen/.hdiconfig`). +- **Node flag is all-or-nothing at runtime.** CAP Node v10 has no separate + `cds.drafts.deleteStrategy: flat|dad` escape hatch (that is the CAP Java + model). On Node, `draft_admin_integrity: true` switches the runtime to rely on + the DB cascade. There is no "constraints present but runtime still flat" safe + middle state on Node — which is exactly why the flag must stay off until the + cascade FK is proven present on the target env. The `CDS_*` env override does + not reach `cds.fiori.draft_admin_integrity`, so the value is whatever + `package.json` bakes at build time. +- **Cascade needed, per table:** `FOREIGN KEY (DraftAdministrativeData_DraftUUID) + REFERENCES DRAFT_DraftAdministrativeData(DraftUUID) ON DELETE CASCADE`, + deployed `NOT VALIDATED` (HANA skips the existing-data check, so no pre-clean + required). Parent table/PK confirmed via `cleanup-draft-orphans.cjs` + (`DRAFT_DRAFTADMINISTRATIVEDATA(DRAFTUUID)`). +- **QA container is out of scope.** The QA db build model is `db-qa` only and + does **not** include `AdminService`; `gen/db-qa/src/gen/` emits **0** draft + tables. QA has no draft shadow tables to protect, so the constraint work is + **prod-container-only** (`gen/db` → `tutorials-hana`), not symmetric with the + existing hdbprocedure patch. + +## The three deliverables + +### 1. Cascade constraint artifacts (authored, committed, NOT deployed-live) + +The build emits the 46 `_drafts.hdbtable` files with the FK column but no +cascade clause, and `.hdiconfig` has no `hdbconstraint` plugin. So the mechanism +is **not** committed `db/src/*.hdbconstraint` (HDI would ignore it). Instead, +add the `FOREIGN KEY (DraftAdministrativeData_DraftUUID) REFERENCES +DRAFT_DraftAdministrativeData(DraftUUID) ON DELETE CASCADE` clause to each +generated `gen/db/src/gen/AdminService.*_drafts.hdbtable` via a **build-time +patch step**, mirroring the existing MTA `before-all` `.hdiconfig` patch +precedent. The table list is derived programmatically from the generated +`_drafts.hdbtable` set (not a hand-maintained list), so it cannot drift from the +live draft-table count the preflight checks. Applied to the **prod `gen/db` +tree only** (QA emits no draft tables). The committed `package.json` flag stays +`false` throughout; the patch makes the constraints *deployable* but the runtime +cascade path stays dormant until a future flip. + +### 2. Flag-agnostic gate test + +Extend the existing `test/admin-drafts-discard-reconcile.test.js` pattern. A new +test: create a Mission draft with child items, `draftEdit`, then discard; assert +**zero** child draft rows remain for that draft. On the in-memory SQLite unit +run the assertion goes through CAP's `.drafts` accessor (e.g. +`CompletionPathItems.drafts` / `GroupPathItems.drafts` filtered by the draft's +`DraftAdministrativeData_DraftUUID`), **not** raw HANA physical-table SQL (those +names don't exist on SQLite, and the HANA cleanup query isn't portable to +`npm test`). Must be green in `npm test` today (proves the app-level reconcile +path leaves no orphans with the flag off) and is the gate re-run before any +future flip. + +### 3. Re-enable runbook + +A doc (under `docs/developers/operations/`) giving the exact order: deploy +constraints (`NOT VALIDATED`) → confirm preflight logs `verified (N/N)` on the +target env → run the gate test green → flip `package.json` flag → full rebuild ++ redeploy → smoke draftEdit/discard. Encodes issue guardrails: flag does not +go on until constraints are green on the *target* env; `CDS_*` env override is +not a substitute for the baked `package.json` value. + +## Constraints + +- Branch from fresh `origin/DEV` (done). PR targets `DEV`; never touch `main` + directly. +- `npm test` (SQLite) must stay green; the gate test is additive. +- No deploy performed by this issue. + +## Left to the builder / plan + +- **O1 (builder's call within plan):** the exact form of the build-time patch + step that appends the cascade FK to each generated + `gen/db/src/gen/AdminService.*_drafts.hdbtable` — e.g. a Node script wired into + the MTA `before-all` alongside the existing `.hdiconfig` patch, vs. a + `cds build` plugin/after-build hook. Decide based on where the existing + hdbprocedure `.hdiconfig` patch lives and what's least likely to drift. The + option set from the *original* draft (committed `.hdbconstraint` vs. runtime + `hdbsql`) is superseded by the corrected Findings: the tables are tracked HDI + artifacts, so patching the generated `.hdbtable` is the lowest-risk mechanism. +- Confirm whether the patch must also register a plugin in `.hdiconfig` — if the + FK is inlined into the `.hdbtable` body (part of the `COLUMN TABLE (...)` + definition), no `hdbconstraint` plugin is needed; if authored as a separate + constraint artifact, it is. Prefer inlining to avoid the plugin dependency. +- Exact Mission child-draft entities for the gate test's assertions + (`CompletionPathItems` / `GroupPathItems` draft accessors). +- Whether to also assert on `draftActivate` (not just discard) in the gate test. +``` From be38da7009680c2b3701c04a9c9eeb945097add2 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 15:03:08 -0600 Subject: [PATCH 2/8] feat(db): build-time ON DELETE CASCADE patch for draft tables, not yet enabled (#2993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds scripts/patch-draft-cascade-constraints.cjs which enumerates all *_drafts.hdbtable files emitted by `npx cds build --production` and injects a FOREIGN KEY ... ON DELETE CASCADE clause for the DraftAdministrativeData_DraftUUID column so HANA can enforce cascade- delete of orphaned draft rows. Wired into the MTA before-all builder (prod gen tree only; QA tree emits zero draft tables). The draft_admin_integrity flag remains false in package.json — this task only makes the constraints deployable. Verified: patched 46 / skipped 0 against a real production build. --- .deploy/mta.yaml | 3 + scripts/patch-draft-cascade-constraints.cjs | 103 ++++++++++++++++++ .../patch-draft-cascade-constraints.test.js | 73 +++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 scripts/patch-draft-cascade-constraints.cjs create mode 100644 test/unit/patch-draft-cascade-constraints.test.js diff --git a/.deploy/mta.yaml b/.deploy/mta.yaml index 1e06c8326..bb32c1a56 100644 --- a/.deploy/mta.yaml +++ b/.deploy/mta.yaml @@ -51,6 +51,9 @@ build-parameters: - bash -c "cd .. && npm run prebuild:parsers-bundle" - bash -c "cd .. && node scripts/bundle-shared.cjs && npx cds build --production" - bash -c "cd .. && node -e 'for (const p of [\"gen/db/src/gen/.hdiconfig\",\"gen/db-qa/src/gen/.hdiconfig\"]) { const fs=require(\"fs\"); const d=JSON.parse(fs.readFileSync(p,\"utf8\")); d.file_suffixes=d.file_suffixes||{}; d.file_suffixes.hdbprocedure={plugin_name:\"com.sap.hana.di.procedure\"}; fs.writeFileSync(p, JSON.stringify(d,null,2)+\"\\n\"); }'" + # Patch prod draft tables with ON DELETE CASCADE FK (#2993). Prod tree only — + # gen/db-qa emits no draft tables so it is intentionally not patched. + - bash -c "cd .. && node scripts/patch-draft-cascade-constraints.cjs gen/db/src/gen" - bash -c "rm -f ../gen/db-qa/src/gen/sap.changelog.*.hdbindex" # Build app/explore + emit the bundle manifest into hugo/data/. # Hugo reads site.Data.explore_bundle at template-render time diff --git a/scripts/patch-draft-cascade-constraints.cjs b/scripts/patch-draft-cascade-constraints.cjs new file mode 100644 index 000000000..1ce214bd0 --- /dev/null +++ b/scripts/patch-draft-cascade-constraints.cjs @@ -0,0 +1,103 @@ +// scripts/patch-draft-cascade-constraints.cjs +// Build-time patch: appends ON DELETE CASCADE foreign keys to generated draft +// .hdbtable files so that SAP HANA will enforce cascade-delete of draft rows +// when their DraftAdministrativeData record is removed. +// +// Background (#2993): CAP's `cds.fiori.draft_admin_integrity` flag requires +// that every *_drafts table carries a real HANA FK with ON DELETE CASCADE to +// DRAFT_DRAFTADMINISTRATIVEDATA. The flag stays OFF in package.json (the +// in-memory SQLite unit tier does not understand .hdbconstraint DDL); this +// script makes the constraints deployable without enabling the flag. +// +// Usage (CLI): +// node scripts/patch-draft-cascade-constraints.cjs [genDir] +// genDir defaults to gen/db/src/gen +// +// Exported API: +// patchDraftCascadeConstraints(genDir) -> { patched: string[], skipped: string[] } + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const FK_COLUMN = 'DraftAdministrativeData_DraftUUID'; +const FK_CLAUSE = + 'FOREIGN KEY ("DraftAdministrativeData_DraftUUID") REFERENCES "DRAFT_DRAFTADMINISTRATIVEDATA"("DRAFTUUID") ON DELETE CASCADE'; + +/** + * Enumerate all *_drafts.hdbtable files under genDir, and for each one that + * contains the FK column but not yet ON DELETE CASCADE, inject the FK clause + * as the last element inside the COLUMN TABLE (...) body. + * + * @param {string} genDir Absolute (or cwd-relative) path to the gen dir. + * @returns {{ patched: string[], skipped: string[] }} + * @throws {Error} when no *_drafts.hdbtable files are found in genDir. + */ +function patchDraftCascadeConstraints(genDir) { + const entries = fs.readdirSync(genDir); + const draftFiles = entries.filter((f) => f.endsWith('_drafts.hdbtable')); + + if (draftFiles.length === 0) { + throw new Error( + `no *_drafts.hdbtable files found in ${genDir} — run cds build --production first` + ); + } + + const patched = []; + const skipped = []; + + for (const filename of draftFiles) { + const filepath = path.join(genDir, filename); + const original = fs.readFileSync(filepath, 'utf8'); + + // Skip if this table doesn't have the FK column (draft tables from other + // namespaces, or tables generated without draft admin support). + if (!original.includes(FK_COLUMN)) { + skipped.push(filename); + continue; + } + + // Idempotency: if the FK clause is already present, skip. + if (/ON DELETE CASCADE/i.test(original)) { + skipped.push(filename); + continue; + } + + // Inject the FK clause as the last item inside the COLUMN TABLE (...) body. + // The closing ) is the last non-whitespace character after PRIMARY KEY(...). + // Strategy: find the last ) and insert ", \n" before it. + const lastParen = original.lastIndexOf(')'); + if (lastParen === -1) { + // Malformed file — skip rather than corrupt it. + skipped.push(filename); + continue; + } + + const patched_content = + original.slice(0, lastParen) + + ',\n ' + + FK_CLAUSE + + '\n' + + original.slice(lastParen); + + fs.writeFileSync(filepath, patched_content, { encoding: 'utf8' }); + patched.push(filename); + } + + return { patched, skipped }; +} + +// CLI entry point +if (require.main === module) { + const genDir = process.argv[2] || 'gen/db/src/gen'; + try { + const result = patchDraftCascadeConstraints(genDir); + console.log(`patched ${result.patched.length} / skipped ${result.skipped.length}`); + } catch (err) { + console.error(err.message); + process.exit(1); + } +} + +module.exports = { patchDraftCascadeConstraints }; diff --git a/test/unit/patch-draft-cascade-constraints.test.js b/test/unit/patch-draft-cascade-constraints.test.js new file mode 100644 index 000000000..4598ec4cb --- /dev/null +++ b/test/unit/patch-draft-cascade-constraints.test.js @@ -0,0 +1,73 @@ +// test/unit/patch-draft-cascade-constraints.test.js +// Unit tests for the build-time cascade-FK patch script (#2993). +// No CDS boot — pure file-system fixture only. + +import { describe, it, expect, beforeEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { patchDraftCascadeConstraints } from '../../scripts/patch-draft-cascade-constraints.cjs'; + +// Minimal .hdbtable fixture that carries the FK column +const FIXTURE_WITH_FK = `COLUMN TABLE AdminService_Missions_drafts ( + ID NVARCHAR(36) NOT NULL, + IsActiveEntity NVARCHAR(1), + HasActiveEntity NVARCHAR(1), + HasDraftEntity NVARCHAR(1), + DraftAdministrativeData_DraftUUID NVARCHAR(36) NOT NULL, + PRIMARY KEY(ID) +)`; + +// Minimal .hdbtable fixture that does NOT carry the FK column +const FIXTURE_WITHOUT_FK = `COLUMN TABLE AdminService_NoFk_drafts ( + ID NVARCHAR(36) NOT NULL, + IsActiveEntity NVARCHAR(1), + PRIMARY KEY(ID) +)`; + +let tmp; +let emptyTmp; + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'patch-draft-test-')); + emptyTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'patch-draft-empty-')); + + fs.writeFileSync( + path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), + FIXTURE_WITH_FK, + { encoding: 'utf8' } + ); + fs.writeFileSync( + path.join(tmp, 'AdminService.NoFk_drafts.hdbtable'), + FIXTURE_WITHOUT_FK, + { encoding: 'utf8' } + ); +}); + +describe('patchDraftCascadeConstraints', () => { + it('appends ON DELETE CASCADE FK to draft tables that carry the FK column', () => { + const r = patchDraftCascadeConstraints(tmp); + const sql = fs.readFileSync(path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), 'utf8'); + expect(sql).toMatch( + /FOREIGN KEY\s*\("?DraftAdministrativeData_DraftUUID"?\)\s*REFERENCES\s+"DRAFT_DRAFTADMINISTRATIVEDATA"\("DRAFTUUID"\)\s+ON DELETE CASCADE/i + ); + expect(r.patched).toContain('AdminService.Missions_drafts.hdbtable'); + }); + + it('skips draft tables without the FK column', () => { + patchDraftCascadeConstraints(tmp); + const sql = fs.readFileSync(path.join(tmp, 'AdminService.NoFk_drafts.hdbtable'), 'utf8'); + expect(sql).not.toMatch(/ON DELETE CASCADE/i); + }); + + it('is idempotent — second run adds no duplicate FK', () => { + patchDraftCascadeConstraints(tmp); + const first = fs.readFileSync(path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), 'utf8'); + patchDraftCascadeConstraints(tmp); + expect(fs.readFileSync(path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), 'utf8')).toBe(first); + }); + + it('throws when no draft tables are found', () => { + expect(() => patchDraftCascadeConstraints(emptyTmp)).toThrow(/no .*_drafts\.hdbtable/i); + }); +}); From e02afbe31df376f3e62b74be71e329dc2ff8ce3b Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 15:23:40 -0600 Subject: [PATCH 3/8] fix(db): trailing-comma DDL form + tighter idempotency guard in draft cascade patch (#2993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings: - Comma now trails PRIMARY KEY(ID) line (trailing-comma style) rather than appearing as a bare leading comma on the separator line. Fixed by trimEnd()ing the body slice so the comma attaches to the last element. - Idempotency regex tightened from /ON DELETE CASCADE/i to a pattern that keys on DraftAdministrativeData_DraftUUID + ON DELETE CASCADE (cross-paren match via [\s\S]*?) so a different column's cascade clause cannot be mistaken for our injection. New test: 'comma trails the previous line — no leading-comma style' asserts PRIMARY KEY(ID), immediately precedes FOREIGN KEY. 5/5 tests pass; real build: patched 46 / skipped 0. --- scripts/patch-draft-cascade-constraints.cjs | 21 ++++++++++++++----- .../patch-draft-cascade-constraints.test.js | 9 ++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/scripts/patch-draft-cascade-constraints.cjs b/scripts/patch-draft-cascade-constraints.cjs index 1ce214bd0..f3d5df55b 100644 --- a/scripts/patch-draft-cascade-constraints.cjs +++ b/scripts/patch-draft-cascade-constraints.cjs @@ -58,15 +58,24 @@ function patchDraftCascadeConstraints(genDir) { continue; } - // Idempotency: if the FK clause is already present, skip. - if (/ON DELETE CASCADE/i.test(original)) { + // Idempotency: if OUR specific FK clause is already present, skip. + // Match the FK column name AND ON DELETE CASCADE on the same logical line. + // Uses [\s\S]*? (non-greedy cross-paren match) because the clause itself + // contains parentheses: FOREIGN KEY (...) REFERENCES ...("DRAFTUUID") ON DELETE CASCADE. + if (/FOREIGN KEY\s*\([^F]*DraftAdministrativeData_DraftUUID[\s\S]*?ON DELETE CASCADE/i.test(original)) { skipped.push(filename); continue; } // Inject the FK clause as the last item inside the COLUMN TABLE (...) body. - // The closing ) is the last non-whitespace character after PRIMARY KEY(...). - // Strategy: find the last ) and insert ", \n" before it. + // Target form: + // PRIMARY KEY(ID), + // FOREIGN KEY ("DraftAdministrativeData_DraftUUID") REFERENCES ... ON DELETE CASCADE + // ) + // + // Strategy: find the last ')' (the table body closing paren). Everything + // before it (trimmed of trailing whitespace/newlines) gets a trailing comma, + // then the FK clause on its own line, then the closing ')'. const lastParen = original.lastIndexOf(')'); if (lastParen === -1) { // Malformed file — skip rather than corrupt it. @@ -74,8 +83,10 @@ function patchDraftCascadeConstraints(genDir) { continue; } + // Trim trailing whitespace from the body so the comma trails the last element. + const bodyPart = original.slice(0, lastParen).trimEnd(); const patched_content = - original.slice(0, lastParen) + + bodyPart + ',\n ' + FK_CLAUSE + '\n' + diff --git a/test/unit/patch-draft-cascade-constraints.test.js b/test/unit/patch-draft-cascade-constraints.test.js index 4598ec4cb..f7d9817e9 100644 --- a/test/unit/patch-draft-cascade-constraints.test.js +++ b/test/unit/patch-draft-cascade-constraints.test.js @@ -54,6 +54,15 @@ describe('patchDraftCascadeConstraints', () => { expect(r.patched).toContain('AdminService.Missions_drafts.hdbtable'); }); + it('comma trails the previous line — no leading-comma style', () => { + patchDraftCascadeConstraints(tmp); + const sql = fs.readFileSync(path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), 'utf8'); + // The separator comma must be attached to PRIMARY KEY(ID), not on its own line. + expect(sql).toMatch(/PRIMARY KEY\(ID\),\s*\n\s*FOREIGN KEY/i); + // Confirm there is no bare leading comma on its own line before FOREIGN KEY. + expect(sql).not.toMatch(/^\s*,\s*\n\s*FOREIGN KEY/im); + }); + it('skips draft tables without the FK column', () => { patchDraftCascadeConstraints(tmp); const sql = fs.readFileSync(path.join(tmp, 'AdminService.NoFk_drafts.hdbtable'), 'utf8'); From 4b3866fbdf29d0b1dd4e6a68604e8b20105b3b56 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 15:39:15 -0600 Subject: [PATCH 4/8] =?UTF-8?q?test(drafts):=20flag-agnostic=20gate=20?= =?UTF-8?q?=E2=80=94=20Mission=20discard=20leaves=20zero=20child=20draft?= =?UTF-8?q?=20orphans=20(#2993)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/admin-drafts-cascade-gate.test.js | 150 +++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 test/admin-drafts-cascade-gate.test.js diff --git a/test/admin-drafts-cascade-gate.test.js b/test/admin-drafts-cascade-gate.test.js new file mode 100644 index 000000000..4e3288aa6 --- /dev/null +++ b/test/admin-drafts-cascade-gate.test.js @@ -0,0 +1,150 @@ +// test/admin-drafts-cascade-gate.test.js +// +// Flag-agnostic gate test for #2993: Mission draftEdit → discard must leave +// zero orphaned child CompletionPathItems draft rows, regardless of whether +// cds.fiori.draft_admin_integrity is true or false. +// +// When draft_admin_integrity is OFF (current default): CAP's built-in draft +// discard deletes the parent Missions draft and its composition children +// (CompletionPaths + CompletionPathItems) in the same operation. The child +// draft rows are removed before any after('DISCARD') hook fires. +// +// When draft_admin_integrity is ON (#2993 target): a DB-level CASCADE constraint +// is added so the same deletion cascade still removes child draft rows even +// though CAP 10.2 changes the discard implementation. +// +// This test proves the invariant holds NOW (flag OFF) so it will turn red +// the moment a future flag-on or schema change breaks the cascade — acting as +// the re-enable gate described in the #2993 design doc. +// +// Harness: cds.test('serve', '--project', '.', '--in-memory') + adminAuth HTTP, +// matching test/admin-drafts-discard-reconcile.test.js. All seeded rows use the +// 93xxx legacyId range to avoid collisions with 92xxx rows in that file. +import { describe, it, expect } from 'vitest'; +import cds from '@sap/cds'; + +const project = cds.test('serve', '--project', '.', '--in-memory'); +const adminAuth = { auth: { username: 'admin', password: 'admin' } }; +const NS = 'com.sap.developers.ims'; + +describe('Mission draftEdit→discard leaves zero child draft orphans (#2993 gate)', () => { + // ─── A) Mission with one CompletionPath + one CompletionPathItem ───────── + // Seed an active Mission composition hierarchy, draftEdit it, confirm + // CompletionPathItems.drafts rows exist for the draft UUID (sanity), then + // discard and assert zero CompletionPathItems.drafts remain for that UUID. + it('leaves zero CompletionPathItems draft rows after Mission draftEdit→discard', async () => { + const db = await cds.connect.to('db'); + const srv = await cds.connect.to('AdminService'); + const { Tutorials, Missions, CompletionPaths, CompletionPathItems } = cds.entities(NS); + + // Unique IDs in the 93xxx range — distinct from the 92xxx range in + // admin-drafts-discard-reconcile.test.js to avoid collision. + const MISS_ID = 'cccccccc-2993-0001-0000-000000000001'; + const CP_ID = 'dddddddd-2993-0001-0000-000000000001'; + const CPI_ID = 'eeeeeeee-2993-0001-0000-000000000001'; + const TUT_ID = 'bbbbbbbb-2993-0001-0000-000000000001'; + const TUT_LEGACY = 93001; + const MISS_LEGACY = 93501; + + // Seed active entities. + await db.run(INSERT.into(Tutorials).entries({ + ID: TUT_ID, legacyId: TUT_LEGACY, slug: 'cascade-gate-tut-01', + title: 'Cascade Gate Tut 01', status: 'ACTIVE', + })); + await db.run(INSERT.into(Missions).entries({ + ID: MISS_ID, legacyId: MISS_LEGACY, + title: '__TEST__ Cascade Gate Mission 01', published: false, + })); + await db.run(INSERT.into(CompletionPaths).entries({ + ID: CP_ID, mission_ID: MISS_ID, legacyId: 93601, name: 'Cascade Gate Path', + })); + await db.run(INSERT.into(CompletionPathItems).entries({ + ID: CPI_ID, path_ID: CP_ID, taskType: 'TUTORIAL', + taskLegacyId: TUT_LEGACY, itemOrder: 10, legacyId: 93701, + })); + + // draftEdit the active Mission. + const { status: sEdit } = await project.post( + `/admin/Missions(ID=${MISS_ID},IsActiveEntity=true)/AdminService.draftEdit`, {}, adminAuth + ); + expect([200, 201]).toContain(sEdit); + + // Capture the DraftAdministrativeData_DraftUUID from the Missions.drafts row. + // srv.entities['Missions'].drafts is the draft-shadow entity exposed by the + // AdminService runtime (the .drafts accessor is on service entities, not raw + // model entities from cds.entities(NS)). + const [missionDraftRow] = await db.run( + SELECT.from(srv.entities['Missions'].drafts).where({ ID: MISS_ID }) + ); + expect(missionDraftRow).toBeTruthy(); + const draftUUID = missionDraftRow.DraftAdministrativeData_DraftUUID; + expect(draftUUID).toBeTruthy(); + + // Sanity: CompletionPathItems.drafts for this UUID must be non-empty + // (proves the test can detect orphans — the assertion is non-vacuous). + const before = await db.run( + SELECT.from(srv.entities['CompletionPathItems'].drafts) + .where({ DraftAdministrativeData_DraftUUID: draftUUID }) + ); + expect(before.length).toBeGreaterThan(0); + + // Discard the draft. + const { status: sDisc } = await project.delete( + `/admin/Missions(ID=${MISS_ID},IsActiveEntity=false)`, adminAuth + ); + expect([200, 204]).toContain(sDisc); + + // Gate assertion: zero CompletionPathItems.drafts must remain after discard. + const after = await db.run( + SELECT.from(srv.entities['CompletionPathItems'].drafts) + .where({ DraftAdministrativeData_DraftUUID: draftUUID }) + ); + expect(after).toHaveLength(0); + }); + + // ─── B) Empty-composition Mission (no child items) ─────────────────────── + // A Mission with no CompletionPaths at all should draftEdit and discard + // cleanly, resulting in zero orphaned child draft rows (trivially zero in, + // zero out — but ensures the code path does not throw on an empty hierarchy). + it('discard of a Mission draft with no child items leaves zero orphans', async () => { + const db = await cds.connect.to('db'); + const srv = await cds.connect.to('AdminService'); + const { Missions } = cds.entities(NS); + + const MISS_ID2 = 'cccccccc-2993-0002-0000-000000000001'; + const MISS_LEGACY2 = 93502; + + // Seed active Mission with no CompletionPaths / CompletionPathItems. + await db.run(INSERT.into(Missions).entries({ + ID: MISS_ID2, legacyId: MISS_LEGACY2, + title: '__TEST__ Cascade Gate Mission 02 (empty)', published: false, + })); + + // draftEdit. + const { status: sEdit } = await project.post( + `/admin/Missions(ID=${MISS_ID2},IsActiveEntity=true)/AdminService.draftEdit`, {}, adminAuth + ); + expect([200, 201]).toContain(sEdit); + + // Capture draft UUID via service entity's .drafts accessor. + const [missionDraftRow] = await db.run( + SELECT.from(srv.entities['Missions'].drafts).where({ ID: MISS_ID2 }) + ); + expect(missionDraftRow).toBeTruthy(); + const draftUUID = missionDraftRow.DraftAdministrativeData_DraftUUID; + expect(draftUUID).toBeTruthy(); + + // Discard. + const { status: sDisc } = await project.delete( + `/admin/Missions(ID=${MISS_ID2},IsActiveEntity=false)`, adminAuth + ); + expect([200, 204]).toContain(sDisc); + + // Zero orphans expected (and zero before, so this is a clean-pass assertion). + const after = await db.run( + SELECT.from(srv.entities['CompletionPathItems'].drafts) + .where({ DraftAdministrativeData_DraftUUID: draftUUID }) + ); + expect(after).toHaveLength(0); + }); +}); From 0aa246e2f63a72ad51dcf9a5725e5c3d67b25f68 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 15:50:57 -0600 Subject: [PATCH 5/8] docs(ops): runbook for safely re-enabling draft_admin_integrity (#2993) --- CLAUDE.md | 2 +- .../operations/draft-integrity-reenable.md | 228 ++++++++++++++++++ 2 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 docs/developers/operations/draft-integrity-reenable.md diff --git a/CLAUDE.md b/CLAUDE.md index ab3678775..5524b4750 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,4 +159,4 @@ The load-bearing few. **Full detail for every relocated item → [tutorials-ims- - **External channels subsystem** — `Channels` entity (`db/channels.cds`) is the source of truth; re-ingest via `npm run seed-channels`; directory at `/channels` (baked by `fetch-channels` in `build:all`); verb-lane fill via `npm run promote-channels`; community items never land in `START_HERE`. → [channels.md](docs/developers/reference/channels.md). - **TechEd sessions subsystem (#2312)** — new `TechEd*` entities (`db/external/teched.cds`, NOT reused `CommunityEvents`), loaded via a **`using` side-effect** in `db/external-content.cds` (don't delete the import); RainFocus ingest (`POST https://events.rainfocus.com/api/sessions`, headers `rfapiprofileid`/`rfwidgetid` per venue from `RAINFOCUS_{TE26,TEV26}_PROFILE`/`_WIDGET` env — live-confirmed defaults baked in, IDs may rotate; speakers embedded in `participants[]`, tracks in `attributevalues[attribute=="Track"]`, offset-paginated); idempotent seed via `scripts/seed-teched.cjs`; public `/build/teched` feed; corrected fetcher + delta cron jobs shipped in #2332; `/teched/` page + island, KG projection, embeddings, and Devtoberfest cross-linking are later units. → [teched.md](docs/developers/reference/teched.md). - **Signed provenance envelope (#2245)** — `PROVENANCE_ENVELOPE_ENABLED` (DB config, default OFF, DEV-first); two anonymous Express endpoints (`/content/tutorials/:slug/provenance` and `/.well-known/tutorial-provenance/jwks.json`); signing key = `PROVENANCE_SIGNING_KEY` credstore secret (Ed25519 PKCS8 PEM); rotate via `/admin-ui/#secrets`. Fail-open. → gotchas.md "Signed provenance envelope". -- **Optimized draft deletion `draft_admin_integrity` ON (#2909, CAP 10.2)** — `cds.fiori.draft_admin_integrity: true` in package.json; draft discard cascade-deletes draft `CompletionPathItems`/`GroupPathItems` at the DB level, so rollup reconcile now fires via `after('DISCARD','Missions.drafts'/'Groups.drafts')` (the `.drafts` target is required), NOT the per-row child `after('DELETE')` hooks (those stay for direct programmatic deletes). Deleting an active entity right after `draftActivate` can 403 ("a draft exists") until the residual draft is discarded. Constraint migration deploys to HANA (`.hdbconstraint`, `NOT VALIDATED`) BEFORE the flag serves. → gotchas.md "Optimized draft deletion". +- **Optimized draft deletion `draft_admin_integrity` ON (#2909, CAP 10.2)** — `cds.fiori.draft_admin_integrity: true` in package.json; draft discard cascade-deletes draft `CompletionPathItems`/`GroupPathItems` at the DB level, so rollup reconcile now fires via `after('DISCARD','Missions.drafts'/'Groups.drafts')` (the `.drafts` target is required), NOT the per-row child `after('DELETE')` hooks (those stay for direct programmatic deletes). Deleting an active entity right after `draftActivate` can 403 ("a draft exists") until the residual draft is discarded. Constraint migration deploys to HANA (`.hdbconstraint`, `NOT VALIDATED`) BEFORE the flag serves. → gotchas.md "Optimized draft deletion". Re-enable runbook: docs/developers/operations/draft-integrity-reenable.md. diff --git a/docs/developers/operations/draft-integrity-reenable.md b/docs/developers/operations/draft-integrity-reenable.md new file mode 100644 index 000000000..f51c4b8f9 --- /dev/null +++ b/docs/developers/operations/draft-integrity-reenable.md @@ -0,0 +1,228 @@ +# Safely Re-enabling `draft_admin_integrity` on Production + +**Status:** DEFERRED — This runbook documents the SAFE re-enable procedure for `cds.fiori.draft_admin_integrity`. The flag is currently **disabled** (`false` in `package.json`). Re-enable ONLY after all green checks — another outage is unacceptable. This runbook exists so it CAN be done safely when the decision is made; it is NOT an instruction to do it now. + +**Context:** +- Issue #2909 (CAP 10.2): draft_admin_integrity ON + missing HANA cascade constraints orphaned all Mission drafts (PROD outage). +- Issue #2992 (Task 1 fix): added preflight check `srv/lib/draft-integrity-preflight.js`; issue #2968 expanded it. +- Issue #2994 (Task 2 fix): added orphan cleanup script `scripts/cleanup-draft-orphans.cjs`. +- Issue #2993 (this work): added build-time FK injection `scripts/patch-draft-cascade-constraints.cjs` + gate test + re-enable runbook. + +**Key facts:** +- Constraints are built `NOT VALIDATED` (no pre-clean needed); they remain **inert** until `draft_admin_integrity: true` is set. +- `CDS_*` env-var overrides do NOT reach `cds.fiori.draft_admin_integrity` on deployed builds — only the baked `package.json` value counts. +- Preflight is HANA-only and fail-open; SQLite unit tests do not require cascade FKs. + +--- + +## Prerequisites + +- [ ] Git branch is up-to-date with `origin/main` (or `origin/DEV` for DEV environments). +- [ ] You have `cf login` credentials for the target environment (DEV first, then PROD). +- [ ] Node.js 22+ and `npm` are installed locally. +- [ ] `@sap/cds-dk` is globally available (`cds --version`). + +--- + +## Step 1: Deploy with Cascade Constraints (Flag Still `false`) + +The Task 1 patch `scripts/patch-draft-cascade-constraints.cjs` already injects `ON DELETE CASCADE` foreign keys into the 46 draft tables in the generated `gen/db/src/gen/AdminService.*_drafts.hdbtable` files. + +**On the target environment (DEV or PROD):** + +1. Ensure the MTA includes the latest `cds build --production` output with the patched constraints. +2. Deploy the MTA as usual (e.g., `npm run deploy -- --env dev` or via CI/CD): + ```bash + npm run build:all + cd .deploy && mbt build && cf deploy mta_archives/*.mtar -e ../deploy/dev.mtaext -f + ``` +3. The HANA schema will accept the constraints as `NOT VALIDATED` — existing orphaned rows are unaffected while the flag is `false`. +4. Verify deployment success and CAP service startup. + +**Note:** The constraints land on every build already (while `draft_admin_integrity` is `false`). This step merely ensures they have deployed to the target environment HANA schema. + +--- + +## Step 2: Verify Preflight Check + +After deployment, the CAP service logs startup messages. Look for the preflight result from `srv/lib/draft-integrity-preflight.js`. + +**Expected log:** +``` +[draft-integrity-preflight] verified (N/N) +``` +where N is the count of verified draft tables (typically 46 for Admin/Mission/Group/Author services + compositions). + +**If you see a WARN instead:** +``` +[draft-integrity-preflight] ⚠ missing: +``` +**STOP.** The constraints are incomplete or not deployed. Do not proceed to Step 3. Investigate: +- Is the MTA's `gen/db` module serving the patched HDbtable files? +- Did the HANA HDI deployment succeed (check `cf logs --recent`)? +- Run `cds build --production` locally and verify `gen/db/src/gen/AdminService.*_drafts.hdbtable` files contain the `ON DELETE CASCADE` clause. + +--- + +## Step 3: Sweep Orphaned Drafts (Dry-Run First) + +Orphaned draft rows are draft-only children whose parent active entity was deleted or reassigned. The cleanup script `scripts/cleanup-draft-orphans.cjs` identifies and removes them. + +**Locally, with credentials bound to the target environment:** + +1. Set up the service binding (one-time): + ```bash + npm run bind:setup # if this is your first hybrid run + ``` + +2. Run a dry-run to inspect any orphans: + ```bash + npx cds bind --exec -- node scripts/cleanup-draft-orphans.cjs --dry-run + ``` + +3. **Expected output:** Zero orphans found: + ``` + Scanning for orphaned drafts... + Found 0 orphaned draft rows. (All clean.) + ``` + +4. **If orphans are found**, inspect them (the script prints details) and decide whether to proceed. If you expect orphans from the #2909 outage: + - Commit/note the findings. + - Run WITHOUT `--dry-run` to delete: + ```bash + npx cds bind --exec -- node scripts/cleanup-draft-orphans.cjs --commit + ``` + - Verify the count matches the dry-run scan. + +--- + +## Step 4: Run the Gate Test (Mission Draft Lifecycle) + +The gate test `test/admin-drafts-cascade-gate.test.js` verifies that Mission draftEdit + discard leaves zero orphaned child drafts. + +**Locally:** +```bash +npx vitest run test/admin-drafts-cascade-gate.test.js +``` + +**Expected:** All tests pass (green), including: +- Draft creation, edit, and discard lifecycle. +- Zero orphaned `Missions.drafts` rows after discard. +- Zero orphaned `CompletionPathItems.drafts` rows after parent discard. + +**If the test fails,** re-examine Step 1–3. Do NOT proceed to Step 5 until this test is green. + +--- + +## Step 5: Flip the Flag in `package.json` + +**ONLY after Steps 1–4 are green on the target environment**, update the flag: + +1. Edit `package.json`: + - Line ~461: find the `"cds": { "fiori": { ... } }` block and set `"draft_admin_integrity": true`. + - Line ~486: find the `"[development]": { "cds": { "fiori": { ... } } }` block and set `"draft_admin_integrity": true`. + +2. Example (before): + ```json + "cds": { + "fiori": { + "draft_admin_integrity": false + } + } + ``` + Example (after): + ```json + "cds": { + "fiori": { + "draft_admin_integrity": true + } + } + ``` + +3. Commit and push the change: + ```bash + git add package.json + git commit -m "feat(config): enable draft_admin_integrity globally (#2993)" + git push origin + ``` + +4. Run a fresh build with the flag ON: + ```bash + npm run build:all + ``` + +5. Deploy to the target environment: + ```bash + cd .deploy && mbt build && cf deploy mta_archives/*.mtar -e ../deploy/dev.mtaext -f + ``` + +--- + +## Step 6: Smoke Test – Mission Discard on Deployed Environment + +After the re-deployed service is running with the flag `true`, verify the behavior end-to-end: + +1. **Via Admin UI** (`/admin-ui/#/admin/missions`): + - Create or open an existing Mission draft. + - Edit it (add a step, change the title, etc.). + - Click **Discard** (or equivalent button). + - Expected: Discard succeeds; no orphaned rows left in `Missions.drafts` or child composition tables. + +2. **Verify no errors in the server logs:** + ```bash + cf logs --recent | grep -i error + ``` + +3. **If discard fails or errors appear,** rollback the flag to `false`, redeploy, and investigate the error. Escalate to the team. + +--- + +## Rollback Plan (Emergency) + +If any step fails or the deployed service encounters errors: + +1. Revert the flag to `false` in `package.json` (Steps 1–2): + ```json + "draft_admin_integrity": false + ``` + +2. Commit, push, and redeploy: + ```bash + git add package.json + git commit -m "revert(config): disable draft_admin_integrity (#2993)" + git push origin + npm run build:all + cd .deploy && mbt build && cf deploy mta_archives/*.mtar -e ../deploy/.mtaext -f + ``` + +3. The constraints remain in the schema (`NOT VALIDATED`, inert); they cause no harm. The flag controls their enforcement. + +4. Investigate the root cause before attempting re-enable again. Involve the team. + +--- + +## Environment Order + +**Always test DEV first, then PROD.** + +- **DEV:** Lower risk; catch any logic gaps before production. +- **PROD:** Only after DEV smoke test passes for at least 2–4 hours without incident. + +--- + +## Related Issues + +- **#2909:** CAP 10.2 draft_admin_integrity ON + missing cascade constraints → PROD outage. +- **#2968:** Preflight check discovered missing cascade FK columns on draft tables. +- **#2992:** Task 1 fix: build-time FK injection + preflight expansion. +- **#2994:** Task 2 fix: orphan cleanup script. +- **#2993:** This work: gate test + re-enable runbook. + +--- + +## Contacts & Escalation + +For questions or issues: +- Check [docs/developers/reference/tutorials-ims-gotchas.md](../reference/tutorials-ims-gotchas.md) for known draft-related gotchas. +- Review the related issues linked above for context. +- Escalate failures to the team via Slack or the issue tracker. From 7beacdf5f926377fb99304c13ffd6bbd8b53bb45 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 15:54:02 -0600 Subject: [PATCH 6/8] fix(docs): correct issue cross-references in draft-integrity-reenable runbook (#2993) --- .../operations/draft-integrity-reenable.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/developers/operations/draft-integrity-reenable.md b/docs/developers/operations/draft-integrity-reenable.md index f51c4b8f9..8e26fcd9c 100644 --- a/docs/developers/operations/draft-integrity-reenable.md +++ b/docs/developers/operations/draft-integrity-reenable.md @@ -3,10 +3,11 @@ **Status:** DEFERRED — This runbook documents the SAFE re-enable procedure for `cds.fiori.draft_admin_integrity`. The flag is currently **disabled** (`false` in `package.json`). Re-enable ONLY after all green checks — another outage is unacceptable. This runbook exists so it CAN be done safely when the decision is made; it is NOT an instruction to do it now. **Context:** -- Issue #2909 (CAP 10.2): draft_admin_integrity ON + missing HANA cascade constraints orphaned all Mission drafts (PROD outage). -- Issue #2992 (Task 1 fix): added preflight check `srv/lib/draft-integrity-preflight.js`; issue #2968 expanded it. -- Issue #2994 (Task 2 fix): added orphan cleanup script `scripts/cleanup-draft-orphans.cjs`. -- Issue #2993 (this work): added build-time FK injection `scripts/patch-draft-cascade-constraints.cjs` + gate test + re-enable runbook. +- Issue #2909: introduced the flag (CAP 10.2) + missing cascade constraints → PROD outage. +- Issue #2968: added the preflight check `srv/lib/draft-integrity-preflight.js`. +- Issue #2992: disabled the flag for all profiles and fixed the preflight (it had queried non-existent `com_sap_developers_ims_*_drafts` names instead of the real `ADMINSERVICE_*_DRAFTS` discovered from `SYS.TABLE_COLUMNS`). +- Issue #2994: added the orphan cleanup sweep `scripts/cleanup-draft-orphans.cjs`. +- Issue #2993 (this work): added the build-time FK injection `scripts/patch-draft-cascade-constraints.cjs`, the gate test `test/admin-drafts-cascade-gate.test.js`, and this runbook. **Key facts:** - Constraints are built `NOT VALIDATED` (no pre-clean needed); they remain **inert** until `draft_admin_integrity: true` is set. @@ -212,11 +213,11 @@ If any step fails or the deployed service encounters errors: ## Related Issues -- **#2909:** CAP 10.2 draft_admin_integrity ON + missing cascade constraints → PROD outage. -- **#2968:** Preflight check discovered missing cascade FK columns on draft tables. -- **#2992:** Task 1 fix: build-time FK injection + preflight expansion. -- **#2994:** Task 2 fix: orphan cleanup script. -- **#2993:** This work: gate test + re-enable runbook. +- **#2909:** Introduced the flag (CAP 10.2) + missing cascade constraints → PROD outage. +- **#2968:** Added the preflight check `srv/lib/draft-integrity-preflight.js`. +- **#2992:** Disabled the flag for all profiles and fixed the preflight (queried non-existent `com_sap_developers_ims_*_drafts` instead of real `ADMINSERVICE_*_DRAFTS` from `SYS.TABLE_COLUMNS`). +- **#2994:** Added the orphan cleanup sweep `scripts/cleanup-draft-orphans.cjs`. +- **#2993:** Added the build-time FK injection `scripts/patch-draft-cascade-constraints.cjs`, the gate test `test/admin-drafts-cascade-gate.test.js`, and this runbook. --- From dcf5e1b2fa5f043f6818fc6daff15b52b06cdf91 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 16:11:27 -0600 Subject: [PATCH 7/8] fix(db): uppercase FK local column + enforced-FK docs correction (#2993) Critical fix: local column in FK clause changed from quoted mixed-case ("DraftAdministrativeData_DraftUUID") to quoted UPPERCASE ("DRAFTADMINISTRATIVEDATA_DRAFTUUID"). The column is declared bare (unquoted) in the generated .hdbtable, so HANA folds it to uppercase; a quoted mixed-case reference is case-sensitive and would not resolve. Confirmed by srv/lib/draft-integrity-preflight.js:93,100 and scripts/cleanup-draft-orphans.cjs:55 both using the uppercase name. Idempotency regex updated to match the uppercase token. Test updated: FK-match assertion now requires "DRAFTADMINISTRATIVEDATA_DRAFTUUID" (exact uppercase, not /i on the column name) and adds a regression guard that fails on the mixed-case quoted form. Docs corrected across runbook, spec, and plan: inline FK is ENFORCED (NOT VALIDATED is an ALTER TABLE affordance only), so orphan sweep must come BEFORE deploying the constraint-bearing build. Runbook reordered: Step 1 = sweep, Step 2 = deploy (with enforced-FK precondition noted). All "NOT VALIDATED / no pre-clean needed" claims removed. Real build: patched 46 / skipped 0; PRIMARY KEY(ID), FOREIGN KEY ("DRAFTADMINISTRATIVEDATA_DRAFTUUID") ... ON DELETE CASCADE confirmed. 5/5 tests pass. --- .../operations/draft-integrity-reenable.md | 81 ++++++++++--------- ...-02-draft-admin-integrity-safe-reenable.md | 4 +- ...ft-admin-integrity-safe-reenable-design.md | 48 ++++++----- scripts/patch-draft-cascade-constraints.cjs | 15 ++-- .../patch-draft-cascade-constraints.test.js | 7 +- 5 files changed, 86 insertions(+), 69 deletions(-) diff --git a/docs/developers/operations/draft-integrity-reenable.md b/docs/developers/operations/draft-integrity-reenable.md index 8e26fcd9c..16dac409d 100644 --- a/docs/developers/operations/draft-integrity-reenable.md +++ b/docs/developers/operations/draft-integrity-reenable.md @@ -10,7 +10,7 @@ - Issue #2993 (this work): added the build-time FK injection `scripts/patch-draft-cascade-constraints.cjs`, the gate test `test/admin-drafts-cascade-gate.test.js`, and this runbook. **Key facts:** -- Constraints are built `NOT VALIDATED` (no pre-clean needed); they remain **inert** until `draft_admin_integrity: true` is set. +- The inline FK (`FOREIGN KEY ... ON DELETE CASCADE` inside the `COLUMN TABLE (...)` body) is created **ENFORCED** by HANA — `NOT VALIDATED` is an `ALTER TABLE` affordance only and cannot be specified inline. This means orphaned draft rows **must be swept BEFORE the constraint-bearing HDbtable is deployed**, or HANA will reject the deploy. - `CDS_*` env-var overrides do NOT reach `cds.fiori.draft_admin_integrity` on deployed builds — only the baked `package.json` value counts. - Preflight is HANA-only and fail-open; SQLite unit tests do not require cascade FKs. @@ -25,11 +25,44 @@ --- -## Step 1: Deploy with Cascade Constraints (Flag Still `false`) +## Step 1: Sweep Orphaned Drafts (Dry-Run First) + +The inline FK is ENFORCED — HANA will reject deploying the constraint if orphaned draft rows already exist in the schema. Sweep orphans **before** deploying the constraint-bearing build. + +**Locally, with credentials bound to the target environment:** + +1. Set up the service binding (one-time): + ```bash + npm run bind:setup # if this is your first hybrid run + ``` + +2. Run a dry-run to inspect any orphans: + ```bash + npx cds bind --exec -- node scripts/cleanup-draft-orphans.cjs --dry-run + ``` + +3. **Expected output:** Zero orphans found: + ``` + Scanning for orphaned drafts... + Found 0 orphaned draft rows. (All clean.) + ``` + +4. **If orphans are found**, inspect them (the script prints details) and decide whether to proceed. If you expect orphans from the #2909 outage: + - Commit/note the findings. + - Run WITHOUT `--dry-run` to delete: + ```bash + npx cds bind --exec -- node scripts/cleanup-draft-orphans.cjs --commit + ``` + - Verify the count matches the dry-run scan. + - Re-run dry-run to confirm zero before proceeding. + +--- + +## Step 2: Deploy with Cascade Constraints (Flag Still `false`) The Task 1 patch `scripts/patch-draft-cascade-constraints.cjs` already injects `ON DELETE CASCADE` foreign keys into the 46 draft tables in the generated `gen/db/src/gen/AdminService.*_drafts.hdbtable` files. -**On the target environment (DEV or PROD):** +**On the target environment (DEV or PROD), after Step 1 confirms zero orphans:** 1. Ensure the MTA includes the latest `cds build --production` output with the patched constraints. 2. Deploy the MTA as usual (e.g., `npm run deploy -- --env dev` or via CI/CD): @@ -37,14 +70,14 @@ The Task 1 patch `scripts/patch-draft-cascade-constraints.cjs` already injects ` npm run build:all cd .deploy && mbt build && cf deploy mta_archives/*.mtar -e ../deploy/dev.mtaext -f ``` -3. The HANA schema will accept the constraints as `NOT VALIDATED` — existing orphaned rows are unaffected while the flag is `false`. +3. The FK is enforced — a clean schema (zero orphans from Step 1) is the precondition for the deploy to succeed. 4. Verify deployment success and CAP service startup. -**Note:** The constraints land on every build already (while `draft_admin_integrity` is `false`). This step merely ensures they have deployed to the target environment HANA schema. +**Note:** The constraints land on every build already (while `draft_admin_integrity` is `false`). This step ensures they have deployed to the target environment HANA schema with a clean baseline. --- -## Step 2: Verify Preflight Check +## Step 3: Verify Preflight Check After deployment, the CAP service logs startup messages. Look for the preflight result from `srv/lib/draft-integrity-preflight.js`. @@ -58,45 +91,13 @@ where N is the count of verified draft tables (typically 46 for Admin/Mission/Gr ``` [draft-integrity-preflight] ⚠ missing: ``` -**STOP.** The constraints are incomplete or not deployed. Do not proceed to Step 3. Investigate: +**STOP.** The constraints are incomplete or not deployed. Do not proceed to Step 4. Investigate: - Is the MTA's `gen/db` module serving the patched HDbtable files? - Did the HANA HDI deployment succeed (check `cf logs --recent`)? - Run `cds build --production` locally and verify `gen/db/src/gen/AdminService.*_drafts.hdbtable` files contain the `ON DELETE CASCADE` clause. --- -## Step 3: Sweep Orphaned Drafts (Dry-Run First) - -Orphaned draft rows are draft-only children whose parent active entity was deleted or reassigned. The cleanup script `scripts/cleanup-draft-orphans.cjs` identifies and removes them. - -**Locally, with credentials bound to the target environment:** - -1. Set up the service binding (one-time): - ```bash - npm run bind:setup # if this is your first hybrid run - ``` - -2. Run a dry-run to inspect any orphans: - ```bash - npx cds bind --exec -- node scripts/cleanup-draft-orphans.cjs --dry-run - ``` - -3. **Expected output:** Zero orphans found: - ``` - Scanning for orphaned drafts... - Found 0 orphaned draft rows. (All clean.) - ``` - -4. **If orphans are found**, inspect them (the script prints details) and decide whether to proceed. If you expect orphans from the #2909 outage: - - Commit/note the findings. - - Run WITHOUT `--dry-run` to delete: - ```bash - npx cds bind --exec -- node scripts/cleanup-draft-orphans.cjs --commit - ``` - - Verify the count matches the dry-run scan. - ---- - ## Step 4: Run the Gate Test (Mission Draft Lifecycle) The gate test `test/admin-drafts-cascade-gate.test.js` verifies that Mission draftEdit + discard leaves zero orphaned child drafts. @@ -196,7 +197,7 @@ If any step fails or the deployed service encounters errors: cd .deploy && mbt build && cf deploy mta_archives/*.mtar -e ../deploy/.mtaext -f ``` -3. The constraints remain in the schema (`NOT VALIDATED`, inert); they cause no harm. The flag controls their enforcement. +3. The enforced constraints remain in the schema but the flag controls whether the runtime relies on them. With the flag `false` the app-level reconcile path runs instead, so the deployed constraints are dormant but harmless. 4. Investigate the root cause before attempting re-enable again. Involve the team. diff --git a/docs/superpowers/plans/2026-11-02-draft-admin-integrity-safe-reenable.md b/docs/superpowers/plans/2026-11-02-draft-admin-integrity-safe-reenable.md index 9b62cffe8..d4c72370f 100644 --- a/docs/superpowers/plans/2026-11-02-draft-admin-integrity-safe-reenable.md +++ b/docs/superpowers/plans/2026-11-02-draft-admin-integrity-safe-reenable.md @@ -17,7 +17,7 @@ - Branch is `worktree-draft-integrity-constraints-2993`, based on `origin/DEV`. PR targets `DEV`, never `main`. - `npm test` (in-memory SQLite) must stay green; the gate test is additive. - Constraint patch applies to the **prod `gen/db` tree only** — QA's db build excludes `AdminService` and emits 0 draft tables. -- Cascade FK form, per table: `FOREIGN KEY (DraftAdministrativeData_DraftUUID) REFERENCES "DRAFT_DRAFTADMINISTRATIVEDATA"("DRAFTUUID") ON DELETE CASCADE` — authored so HANA deploys it `NOT VALIDATED` (skips existing-data check; no pre-clean needed). +- Cascade FK form, per table: `FOREIGN KEY ("DRAFTADMINISTRATIVEDATA_DRAFTUUID") REFERENCES "DRAFT_DRAFTADMINISTRATIVEDATA"("DRAFTUUID") ON DELETE CASCADE` — the local column is declared bare/unquoted in the generated DDL, so HANA folds it to uppercase; the FK reference must quote the uppercase name. The inline FK is ENFORCED (not `NOT VALIDATED` — that is an ALTER TABLE affordance only); orphaned rows must be swept BEFORE deploying the constraint-bearing build. - Draft table list is derived programmatically from the generated `*_drafts.hdbtable` files — never hand-maintained (must not drift from the preflight's live `SYS.TABLE_COLUMNS` count). ## Review Focus @@ -167,7 +167,7 @@ git commit -m "test(drafts): flag-agnostic gate — Mission discard leaves zero - [ ] **Step 1: Write the runbook** -Document the exact order, encoding the spec guardrails: (1) deploy with the Task-1 patch active so constraints land `NOT VALIDATED` while the flag is still `false` (constraints inert); (2) confirm `srv/lib/draft-integrity-preflight.js` logs `verified (N/N)` on the target env (not a WARN); (3) run `scripts/cleanup-draft-orphans.cjs --dry-run` → expect 0 orphans; (4) run the Task-2 gate test green; (5) only then flip `draft_admin_integrity` to `true` in `package.json` (both blocks), full `npm run build:all` + redeploy; (6) smoke a Mission draftEdit→discard. Note: the `CDS_*` env override does NOT work on the deployed build — the baked `package.json` value is authoritative. DEV before PROD. +Document the exact order, encoding the spec guardrails: (1) sweep orphans first — `scripts/cleanup-draft-orphans.cjs --dry-run` then `--commit` if needed — the enforced inline FK will reject the deploy if orphaned rows are present; (2) deploy with the Task-1 patch active so constraints land (enforced, schema must be clean); (3) confirm `srv/lib/draft-integrity-preflight.js` logs `verified (N/N)` on the target env (not a WARN); (4) run the Task-2 gate test green; (5) only then flip `draft_admin_integrity` to `true` in `package.json` (both blocks), full `npm run build:all` + redeploy; (6) smoke a Mission draftEdit→discard. Note: the `CDS_*` env override does NOT work on the deployed build — the baked `package.json` value is authoritative. DEV before PROD. - [ ] **Step 2: Add CLAUDE.md pointer** diff --git a/docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md b/docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md index 22d433521..3a1c25e27 100644 --- a/docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md +++ b/docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md @@ -49,8 +49,9 @@ the proof*, not the switch. - Any change that could cause the DB-cascade delete path to become active on a live environment (i.e. the flag going on, directly or via a build artifact that behaves like enabling it). -- Committed artifacts that `cf deploy` would apply as `VALIDATED` (would block - deploy on existing orphan data) or that `die` the deploy if a referenced +- Committed artifacts that `cf deploy` would reject because of existing orphan data + (the enforced inline FK fails if orphaned draft rows are present — sweep first + using `cleanup-draft-orphans.cjs`) or that `die` the deploy if a referenced draft table does not yet exist on the target. ## Findings (verified this session) @@ -81,10 +82,14 @@ the proof*, not the switch. cascade FK is proven present on the target env. The `CDS_*` env override does not reach `cds.fiori.draft_admin_integrity`, so the value is whatever `package.json` bakes at build time. -- **Cascade needed, per table:** `FOREIGN KEY (DraftAdministrativeData_DraftUUID) - REFERENCES DRAFT_DraftAdministrativeData(DraftUUID) ON DELETE CASCADE`, - deployed `NOT VALIDATED` (HANA skips the existing-data check, so no pre-clean - required). Parent table/PK confirmed via `cleanup-draft-orphans.cjs` +- **Cascade needed, per table:** `FOREIGN KEY ("DRAFTADMINISTRATIVEDATA_DRAFTUUID") + REFERENCES "DRAFT_DRAFTADMINISTRATIVEDATA"("DRAFTUUID") ON DELETE CASCADE`, + deployed ENFORCED (inline FK inside `COLUMN TABLE (...)` is always enforced — + `NOT VALIDATED` is an `ALTER TABLE`-only affordance). This means orphaned draft + rows must be swept BEFORE deploying the constraint, or HANA rejects the deploy. + Local column is bare/unquoted in the generated DDL, so HANA folds it to + `DRAFTADMINISTRATIVEDATA_DRAFTUUID`; the FK reference must quote that uppercase + name. Parent table/PK confirmed via `cleanup-draft-orphans.cjs` (`DRAFT_DRAFTADMINISTRATIVEDATA(DRAFTUUID)`). - **QA container is out of scope.** The QA db build model is `db-qa` only and does **not** include `AdminService`; `gen/db-qa/src/gen/` emits **0** draft @@ -99,16 +104,18 @@ the proof*, not the switch. The build emits the 46 `_drafts.hdbtable` files with the FK column but no cascade clause, and `.hdiconfig` has no `hdbconstraint` plugin. So the mechanism is **not** committed `db/src/*.hdbconstraint` (HDI would ignore it). Instead, -add the `FOREIGN KEY (DraftAdministrativeData_DraftUUID) REFERENCES -DRAFT_DraftAdministrativeData(DraftUUID) ON DELETE CASCADE` clause to each +add the `FOREIGN KEY ("DRAFTADMINISTRATIVEDATA_DRAFTUUID") REFERENCES +"DRAFT_DRAFTADMINISTRATIVEDATA"("DRAFTUUID") ON DELETE CASCADE` clause to each generated `gen/db/src/gen/AdminService.*_drafts.hdbtable` via a **build-time patch step**, mirroring the existing MTA `before-all` `.hdiconfig` patch -precedent. The table list is derived programmatically from the generated -`_drafts.hdbtable` set (not a hand-maintained list), so it cannot drift from the -live draft-table count the preflight checks. Applied to the **prod `gen/db` -tree only** (QA emits no draft tables). The committed `package.json` flag stays -`false` throughout; the patch makes the constraints *deployable* but the runtime -cascade path stays dormant until a future flip. +precedent. The FK is enforced (inline DDL); orphaned rows must be swept before +the constraint-bearing build is deployed. The table list is derived +programmatically from the generated `_drafts.hdbtable` set (not a hand-maintained +list), so it cannot drift from the live draft-table count the preflight checks. +Applied to the **prod `gen/db` tree only** (QA emits no draft tables). The +committed `package.json` flag stays `false` throughout; the patch makes the +constraints *deployable* but the runtime cascade path stays dormant until a +future flip. ### 2. Flag-agnostic gate test @@ -125,12 +132,13 @@ future flip. ### 3. Re-enable runbook -A doc (under `docs/developers/operations/`) giving the exact order: deploy -constraints (`NOT VALIDATED`) → confirm preflight logs `verified (N/N)` on the -target env → run the gate test green → flip `package.json` flag → full rebuild -+ redeploy → smoke draftEdit/discard. Encodes issue guardrails: flag does not -go on until constraints are green on the *target* env; `CDS_*` env override is -not a substitute for the baked `package.json` value. +A doc (under `docs/developers/operations/`) giving the exact order: sweep orphans +first (`cleanup-draft-orphans.cjs --dry-run` then `--commit` if needed) → deploy +constraints (enforced inline FK — clean schema required) → confirm preflight +logs `verified (N/N)` on the target env → run the gate test green → flip +`package.json` flag → full rebuild + redeploy → smoke draftEdit/discard. Encodes +issue guardrails: flag does not go on until constraints are green on the *target* +env; `CDS_*` env override is not a substitute for the baked `package.json` value. ## Constraints diff --git a/scripts/patch-draft-cascade-constraints.cjs b/scripts/patch-draft-cascade-constraints.cjs index f3d5df55b..306173299 100644 --- a/scripts/patch-draft-cascade-constraints.cjs +++ b/scripts/patch-draft-cascade-constraints.cjs @@ -22,8 +22,11 @@ const fs = require('fs'); const path = require('path'); const FK_COLUMN = 'DraftAdministrativeData_DraftUUID'; +// Local column is declared BARE (unquoted) in the generated .hdbtable, so HANA +// stores its name folded to UPPERCASE: DRAFTADMINISTRATIVEDATA_DRAFTUUID. +// The FK reference must therefore quote the UPPERCASE name to resolve correctly. const FK_CLAUSE = - 'FOREIGN KEY ("DraftAdministrativeData_DraftUUID") REFERENCES "DRAFT_DRAFTADMINISTRATIVEDATA"("DRAFTUUID") ON DELETE CASCADE'; + 'FOREIGN KEY ("DRAFTADMINISTRATIVEDATA_DRAFTUUID") REFERENCES "DRAFT_DRAFTADMINISTRATIVEDATA"("DRAFTUUID") ON DELETE CASCADE'; /** * Enumerate all *_drafts.hdbtable files under genDir, and for each one that @@ -59,10 +62,10 @@ function patchDraftCascadeConstraints(genDir) { } // Idempotency: if OUR specific FK clause is already present, skip. - // Match the FK column name AND ON DELETE CASCADE on the same logical line. - // Uses [\s\S]*? (non-greedy cross-paren match) because the clause itself - // contains parentheses: FOREIGN KEY (...) REFERENCES ...("DRAFTUUID") ON DELETE CASCADE. - if (/FOREIGN KEY\s*\([^F]*DraftAdministrativeData_DraftUUID[\s\S]*?ON DELETE CASCADE/i.test(original)) { + // Match DRAFTADMINISTRATIVEDATA_DRAFTUUID (the HANA-folded uppercase name we + // emit) + ON DELETE CASCADE. Uses [\s\S]*? because the clause contains + // parentheses that [^)]* cannot cross. + if (/FOREIGN KEY\s*\([^F]*DRAFTADMINISTRATIVEDATA_DRAFTUUID[\s\S]*?ON DELETE CASCADE/i.test(original)) { skipped.push(filename); continue; } @@ -70,7 +73,7 @@ function patchDraftCascadeConstraints(genDir) { // Inject the FK clause as the last item inside the COLUMN TABLE (...) body. // Target form: // PRIMARY KEY(ID), - // FOREIGN KEY ("DraftAdministrativeData_DraftUUID") REFERENCES ... ON DELETE CASCADE + // FOREIGN KEY ("DRAFTADMINISTRATIVEDATA_DRAFTUUID") REFERENCES ... ON DELETE CASCADE // ) // // Strategy: find the last ')' (the table body closing paren). Everything diff --git a/test/unit/patch-draft-cascade-constraints.test.js b/test/unit/patch-draft-cascade-constraints.test.js index f7d9817e9..d8786e0d8 100644 --- a/test/unit/patch-draft-cascade-constraints.test.js +++ b/test/unit/patch-draft-cascade-constraints.test.js @@ -48,9 +48,14 @@ describe('patchDraftCascadeConstraints', () => { it('appends ON DELETE CASCADE FK to draft tables that carry the FK column', () => { const r = patchDraftCascadeConstraints(tmp); const sql = fs.readFileSync(path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), 'utf8'); + // Local column must be quoted UPPERCASE (HANA folds bare column names to upper). + // A mixed-case quoted reference ("DraftAdministrativeData_DraftUUID") would be + // case-sensitive and fail to resolve against the stored UPPERCASE name. expect(sql).toMatch( - /FOREIGN KEY\s*\("?DraftAdministrativeData_DraftUUID"?\)\s*REFERENCES\s+"DRAFT_DRAFTADMINISTRATIVEDATA"\("DRAFTUUID"\)\s+ON DELETE CASCADE/i + /FOREIGN KEY\s*\("DRAFTADMINISTRATIVEDATA_DRAFTUUID"\)\s*REFERENCES\s+"DRAFT_DRAFTADMINISTRATIVEDATA"\("DRAFTUUID"\)\s+ON DELETE CASCADE/ ); + // Regression guard: must NOT emit the mixed-case quoted form. + expect(sql).not.toMatch(/FOREIGN KEY\s*\("DraftAdministrativeData_DraftUUID"\)/); expect(r.patched).toContain('AdminService.Missions_drafts.hdbtable'); }); From 1f1341f11a64729cc089340c72869b8afc0d1150 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 2 Nov 2026 16:15:06 -0600 Subject: [PATCH 8/8] docs(spec): remove stale (NOT VALIDATED) parenthetical from Goals section (#2993) --- .../2026-11-02-draft-admin-integrity-safe-reenable-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md b/docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md index 3a1c25e27..9b1ff849c 100644 --- a/docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md +++ b/docs/superpowers/specs/2026-11-02-draft-admin-integrity-safe-reenable-design.md @@ -24,7 +24,7 @@ the proof*, not the switch. 1. **Author the real cascade constraints as committed, deployable artifacts** for every `AdminService.*_drafts` table. The build already emits these tables with the `DraftAdministrativeData_DraftUUID` FK column but no cascade clause; - the work adds `ON DELETE CASCADE` (`NOT VALIDATED`) via a build-time patch of + the work adds `ON DELETE CASCADE` via a build-time patch of the generated `.hdbtable` files (see Findings + deliverable 1). They must be deployable independently of the flag. 2. **A flag-agnostic gate test** proving `draftEdit → discard` on a Mission