"REW P1 protection invariant — DB CHECK constraint + service-layer guard forbidding any P1 cash reduction (raise-only)"
§1 — Description (BCP-14 normative)
The REW service MUST ship P1 protection at services/rew/src/p1_guard/ with DB trigger + service guard + demotion consent flow, 3 memory audit kinds.
- MUST validate
p1_change_kindagainst closed enum per DEC-2171.
- MUST create DB trigger at migration
0003: ```sql CREATE OR REPLACE FUNCTION rew_p1_protection_trigger() RETURNS TRIGGER AS $$ DECLARE old_amount BIGINT; new_amount BIGINT; consent_exists BOOLEAN; BEGIN IF NEW.income_kind != 'p1_base' THEN RETURN NEW; END IF; -- get prior current P1 SELECT decrypt_amount(encrypted_amount_vnd, encryption_kms_key_arn) INTO old_amount FROM rew_comp_records WHERE tenant_id = NEW.tenant_id AND member_id = NEW.member_id AND income_kind = 'p1_base' AND valid_from <= NEW.valid_from AND (valid_to IS NULL OR valid_to > NEW.valid_from) ORDER BY valid_from DESC LIMIT 1; IF old_amount IS NULL THEN RETURN NEW; END IF; -- first P1, allowed new_amount := decrypt_amount(NEW.encrypted_amount_vnd, NEW.encryption_kms_key_arn); IF new_amount >= old_amount THEN RETURN NEW; END IF; -- reduction attempt; check consent SELECT EXISTS(SELECT 1 FROM rew_p1_demotion_consents WHERE member_id = NEW.member_id AND tenant_id = NEW.tenant_id AND new_p1_vnd = new_amount AND status = 'fully_signed') INTO consent_exists; IF NOT consent_exists THEN RAISE EXCEPTION 'P1 reduction without consent: member=% old=% new=%', NEW.member_id, old_amount, new_amount; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql;
CREATE TRIGGER rew_p1_protection_trg BEFORE INSERT ON rew_comp_records FOR EACH ROW EXECUTE FUNCTION rew_p1_protection_trigger();
CREATE TABLE rew_p1_demotion_consents ( consent_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, member_id UUID NOT NULL, old_p1_vnd BIGINT NOT NULL, new_p1_vnd BIGINT NOT NULL CHECK (new_p1_vnd < old_p1_vnd), member_consent_doc_id UUID NOT NULL, ceo_signed_by UUID, ceo_signed_at TIMESTAMPTZ, cfo_signed_by UUID, cfo_signed_at TIMESTAMPTZ, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','ceo_signed','cfo_signed','fully_signed','dismissed')), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ALTER TABLE rew_p1_demotion_consents ENABLE ROW LEVEL SECURITY; CREATE POLICY p1_consent_rls ON rew_p1_demotion_consents USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON rew_p1_demotion_consents FROM cyberos_app; GRANT UPDATE (status, ceo_signed_by, ceo_signed_at, cfo_signed_by, cfo_signed_at) ON rew_p1_demotion_consents TO cyberos_app; ```
- MUST validate at service layer at
validator.rs::validate(member, new_p1)per DEC-2170:
- Fetch current P1
- If new < current AND no consent: reject before DB attempt (better UX than trigger fail)
- MUST run consent flow at
demotion_consent.rs::create_request(member, new_p1, consent_doc_id)per DEC-2172:
- Member uploads written consent doc (TASK-DOC-001 ref)
- CEO + CFO sign
- fully_signed → trigger now allows reduction
- MUST expose endpoints: ``
text POST /v1/rew/p1-demotion-consents POST /v1/rew/p1-demotion-consents/{id}/ceo-sign POST /v1/rew/p1-demotion-consents/{id}/cfo-sign``
- MUST emit 3 memory audit kinds per DEC-2174. PII per TASK-MEMORY-111: amounts SHA-256 hashed.
- MUST thread trace_id from violation/consent → audit.
- MUST NOT bypass trigger per DEC-2173.
- MUST NOT allow demotion without consent + dual-sign per DEC-2172.
§2 — Why this design
Why DB trigger (DEC-2173)? Defense in depth — service code can have bugs; trigger is hard backstop.
Why service validator (DEC-2173)? Better UX — fail at API not at DB; clearer error message.
Why demotion consent (DEC-2172)? VN Labour Code Art. 35 requires written agreement to reduce salary.
Why dual-sign (DEC-2172)? Critical financial decision; single-signer governance gap.
§3 — API contract
Sample consent request:
POST /v1/rew/p1-demotion-consents
{
"member_id": "uuid",
"old_p1_vnd": 30000000,
"new_p1_vnd": 25000000,
"member_consent_doc_id": "uuid-signed-pdf"
}
§4 — Acceptance criteria
- p1_change_kind enum cardinality 4. 2. DB trigger rejects P1 reduction without consent. 3. Service validator rejects pre-DB. 4. Consent table requires member doc upload. 5. CEO + CFO dual-sign required. 6. Same-person dual-sign rejected. 7. Once fully_signed → trigger allows. 8. 3 memory audit kinds emitted. 9. PII scrubbed (amounts SHA256). 10. RLS denies cross-tenant. 11. Trace_id preserved. 12. Append-only consent table. 13. CHECK new_p1 < old_p1 on consent. 14. CHECK new_p1 != old_p1 (no-op rejected). 15. First P1 (no prior) allowed. 16. P2/P3 changes unaffected. 17. Trigger fires BEFORE INSERT. 18. Consent links to TASK-DOC-001 doc. 19. CFO role required on consent record. 20. Trigger violation produces specific error message.
§5 — Verification
#[tokio::test]
async fn p1_reduction_without_consent_rejected() {
let ctx = TestContext::with_p1_30m_vnd().await;
let r = ctx.try_set_p1(ctx.member_id, dec!(25_000_000)).await;
assert!(r.is_err());
assert!(r.error_message().contains("P1 reduction without consent"));
}
#[tokio::test]
async fn p1_raise_allowed() {
let ctx = TestContext::with_p1_30m_vnd().await;
let r = ctx.set_p1(ctx.member_id, dec!(35_000_000)).await;
assert!(r.is_ok());
}
#[tokio::test]
async fn demotion_with_full_consent_allowed() {
let ctx = TestContext::with_p1_30m_vnd().await;
let consent = ctx.create_demotion_consent(ctx.member_id, dec!(25_000_000)).await;
ctx.ceo_sign(consent.id).await;
ctx.cfo_sign(consent.id).await;
let r = ctx.set_p1(ctx.member_id, dec!(25_000_000)).await;
assert!(r.is_ok());
}
// 5.4..5.10
§7 — Dependencies
Upstream: TASK-REW-001. Cross-module: TASK-AUTH-101 (CEO/CFO roles), TASK-DOC-001 (consent doc), TASK-MEMORY-111 (PII).
§10 — Failure modes
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| Trigger fires | EXCEPTION | sev-2 audit; reject | get consent |
| Service guard fail | early return | 400 | inherent |
| Consent doc missing | FK NULL | reject | upload doc |
| Same-person dual-sign | validate | 403 | different signer |
| CHECK new == old | reject | 400 | use different value |
| Decryption fail in trigger | trigger error | sev-1 | KMS check |
| Cross-tenant consent | RLS | not found → block | inherent |
| Trigger bypass attempt | inherent in design | sev-1 | inherent |
| Decimal precision | bigint VND | inherent | inherent |
| Concurrent consent + raise | timing window | trigger uses latest | inherent |
§11 — Implementation notes
- §11.1 Trigger uses helper function
decrypt_amount()— security-definer or call-out to KMS via FDW. - §11.2 Service validator pre-rejects for UX; trigger is final guarantee.
- §11.3 Demotion consent doc per VN Labour Code Art. 35 — written agreement scanned + TASK-DOC-001.
- §11.4 memory audit body: member_id, change kind, attempted_violation flag; amounts SHA256.
- §11.5 P2/P3 reductions allowed (variable pay) — invariant scope is P1 only.
End of TASK-REW-003 spec.