Keeps the parallel new tables (mig 136, Slice B.1) in lock-step with
the legacy paliad.deadline_rules table through every write path on
RuleEditorService. Read paths stay on deadline_rules in B.2 — B.3
flips them and stops legacy writes.
* internal/services/dual_write.go (new) —
- syncDualWriteFromDeadlineRule(ctx, tx, id): idempotent UPSERT of
legal_sources + procedural_events + sequencing_rules from the
just-written deadline_rules row. Pure SQL projection, no Go-side
struct mapping. Synthetic-code mint expression is byte-identical
to mig 136 ('null.' || first 8 hex of stripped uuid).
- syncDeadlineDualLinks(ctx, tx, deadlineID): mirrors a deadline's
legacy rule_id back-link onto deadlines.procedural_event_id +
sequencing_rule_id. Handles NULL rule_id naturally (collapses both
new columns to NULL).
- CheckDualWriteDrift(ctx, conn): nine read-only count queries +
integrity joins. Returns DualWriteDriftReport. HasDrift() bool for
log routing.
- StartDualWriteDriftCheckLoop(ctx, conn, interval): goroutine ticker
that runs CheckDualWriteDrift every `interval` (default 6h) for
the lifetime of ctx. Clean run logs at INFO; drift at WARN with
full report.
* internal/services/rule_editor_service.go —
- Create / UpdateDraft / CloneAsDraft / Publish / flipLifecycle
each call syncDualWriteFromDeadlineRule(ctx, tx, id) after the
deadline_rules mutation, before tx.Commit. Publish syncs BOTH the
published draft AND the cloned-from peer it just archived as a
cascade. The audit_reason already set via setAuditReasonTx
propagates to the new-table writes (same TX, same session).
* internal/services/rule_editor_orphans.go —
- ResolveOrphan calls syncDeadlineDualLinks after UPDATE
paliad.deadlines SET rule_id = $1, so the parallel new columns
follow the legacy back-link.
* internal/services/deadline_service.go —
- DeadlineService.Update calls syncDeadlineDualLinks when
input.RuleSet is true (auto/custom rule swap from t-paliad-258).
* cmd/server/main.go —
- Spawns StartDualWriteDriftCheckLoop alongside CalDAV sync and
reminder scanner. Inherits bgCtx so the goroutine stops on
SIGTERM. Interval 6h.
* internal/services/dual_write_test.go (new) —
- TestDualWrite_RuleEditorLifecycle: Create → UpdateDraft → Publish
→ Archive, asserts the new tables mirror at each step. Final
CheckDualWriteDrift returns zero drift.
- TestDualWrite_SyntheticCodeForNullSubmission: rule created with
submission_code=NULL gets a 'null.<8hex>' procedural_events row
matching mig 136's mint expression byte-for-byte.
Scope decisions documented in the commit:
- B.2 keeps read paths on deadline_rules. paliadin's "Read paths fall
back to legacy" reads as "reads stay on legacy as the safety net
while drift-check validates the new tables". B.3 swaps reads to
new tables only AND stops writing to deadline_rules — that's a
separate slice per the design's §5.2/§5.3 split.
- B.2 does NOT modify submission_drafts, projection_service, the
Fristenrechner calculator, the SubmissionVarsService, the
Schriftsätze list query, or any other reader. They keep reading
deadline_rules unchanged. The new tables are populated in parallel
for B.3's cutover.
- Audit triggers on deadline_rules continue to fire as before. The
new tables have no audit triggers yet (a later slice can add
parallel audit rows once the new tables are authoritative).
- Drift-check uses default 6h interval — short enough that a broken
dual-write surfaces within the same business day, long enough that
the count-COUNTs don't churn the pool. Override via the caller in
cmd/server.
Hard rules followed:
- audit_reason set on every TX before any deadline_rules mutation
(existing pattern; new-table writes share the same reason).
- No destructive op (B.2 is strictly additive in behaviour).
- New helpers idempotent (UPSERT ON CONFLICT DO UPDATE) — safe to
call twice, safe to re-run after a partial failure.
Build + vet clean. TestMigrations_NoDuplicateSlot passes.