Skip to content

LEG-01: Policy Registry & Acceptance Ledger

PR #30 hardening applied on top of this design in two passes, marked inline throughout rather than as a separate document since this is one continuously-evolving design:

  • First pass closed a P1 finding ("changed API contracts, no corresponding frontend integration; non-production environments fail closed with no safe bootstrap path"): frontend registration/checkout/seller-onboarding integration ("Frontend integration" below), the --bootstrap-policy-registry staging/production publication tool ("Staging bootstrap" below), a mandatory (no longer optional/nullable) IPolicyComplianceService on checkout ("Checkout dependency: mandatory, not optional" below), and a checkout-409 JSON casing fix ("Checkout 409 response JSON casing" below).
  • Second pass closed a remaining P1: the first pass's checkout flow still auto-resumed payment immediately after policy acceptance, rather than requiring a separate, explicit user action - see "Checkout reacceptance: no automatic retry" below.

Purpose

Implements LEG-01 only: a server-authoritative registry of policy document versions (PolicyRegistry) and an immutable, idempotent ledger of who accepted which version (PolicyAcceptance), plus three enforcement checkpoints (registration, checkout, seller onboarding). LEG-02 (product statutory fields), LEG-03 (seller KYC), the grievance subsystem itself, and the DPDP deletion workflow are explicitly out of scope for this PR - see "Remaining LEG-02+ work" below.

All legal document text this tranche references (docs/legal/CUSTOMER_TERMS.md/PRIVACY_POLICY.md/RETURN_POLICY.md/REFUND_POLICY.md/ SELLER_AGREEMENT.md/SELLER_PRIVACY_NOTICE.md/GRIEVANCE_POLICY.md) remains exactly what it already was: "DRAFT — NOT APPROVED FOR PRODUCTION — REQUIRES INDIA LEGAL REVIEW". This tranche does not edit that content, does not compute a hash from it, and does not treat it as production-approved anywhere. See "Seeding" below for exactly what this tranche does and does not do with those files.

These files were first noted (but deliberately not incorporated) in Policy Acceptance Infrastructure (Work Package G) - that document's earlier, simpler PolicyAcceptance-only design (no separate registry table) is now superseded by the model below; that page is left in place as historical record and updated with a pointer here.

Model

PolicyRegistry

One row per published policy document version. Server-authoritative: no enforcement code path anywhere accepts a client-supplied version or hash as the thing being recorded.

Field Notes
Id
PolicyType Closed enum - CustomerTerms, PrivacyPolicy, ReturnRefundPolicy, SellerAgreement, SellerPrivacyNotice, GrievancePolicy
Version Opaque app-defined string (e.g. "v0.1-DRAFT") - never parsed as semver
EffectiveDate See "Deterministic current-policy selection"
DocumentHash SHA-256 hex (64 chars) - PolicyDocumentHasher.ComputeHash
IsActive Administrative on/off switch - see below for why this is not itself a DB-level "exactly one" constraint
RequiresReacceptance See "Reacceptance evaluation"
SupersedesPolicyId Informational only - no enforcement code reads it
CreatedAt

PolicyAcceptance

One immutable row per (subject, specific policy version) pair - a newer policy version always requires a new row, never an update to an existing one.

Field Notes
Id
PolicyRegistryId FK - which specific version was accepted
UserId / SellerId Exactly one is ever set - see PolicyAcceptance.Create's validation
AcceptedAt
AcceptanceChannel Free text ("web-registration", "web-checkout", "seller-onboarding", ...) - mirrors NotificationConsent.Source's existing convention
IpAddress / UserAgent Always server-observed from the live HTTP request, never client-supplied
WithdrawnAt / WithdrawalReason Reserved, unused by any LEG-01 code path - see "Withdrawal metadata" below
SubjectKey Computed, non-nullable - the real idempotency key, see "MySQL nullable unique-index semantics"

Withdrawal metadata

The tranche brief asks for "withdrawal/revocation metadata if semantically required." Judgment call for LEG-01: legal-document acceptances (Terms/Privacy/Seller Agreement) are not the same kind of thing as a marketing opt-in - a customer cannot meaningfully "un-accept" terms they already transacted under; the correct mechanism for a superseded policy is a new version, not a withdrawal of the old acceptance record. WithdrawnAt/WithdrawalReason exist on the entity as reserved, additive-only fields (a genuine future DPDP-driven consent-withdrawal feature would use them) but nothing in this PR calls PolicyAcceptance.Withdraw - version supersession is handled entirely by the PolicyRegistry join (see "Deterministic current-policy selection"), which needs no bulk update against historical acceptance rows when a new version is activated.

Server authority

IPolicyComplianceService.RecordAcceptanceAsync(policyType, userId, sellerId, channel, ip, userAgent) has no version/hash parameter at all - structurally, a caller cannot supply one. The server always resolves "the current active policy" itself (IPolicyRegistryRepository.GetCurrentActiveAsync) and persists a PolicyAcceptance pointing at that resolved row. The same is true one layer up, at the HTTP boundary: PoliciesController.AcceptPolicyRequest has exactly one property (PolicyType) - proven by a dedicated reflection test (AcceptPolicyRequest_HasNoVersionOrHashField_ CannotEvenBeSpoofedAtTheApiLayer), not just by convention.

Deterministic current-policy selection

IPolicyRegistryRepository.GetCurrentActiveAsync(policyType, asOf):

WHERE PolicyType = @type AND IsActive = 1 AND EffectiveDate <= @asOf
ORDER BY EffectiveDate DESC, CreatedAt DESC
LIMIT 1

Two deliberate properties this gives:

  • A row can be IsActive=true with a future EffectiveDate - an operator can pre-stage a known-upcoming version ahead of time; it is correctly excluded from "current" until its effective date arrives, with no code deploy required for the cutover. "Inactive/future policy does not block" is this property, proven directly (GetCurrentActivePolicyAsync_FuturePolicy_DoesNotBlock_CurrentStaysThePriorVersion).
  • The ordering is a deterministic tie-break, not merely "pick any active row" - if a data anomaly ever left more than one row simultaneously matching (it shouldn't - see below), the same row is returned every time (GetCurrentActivePolicyAsync_MultipleActiveEffectiveRows_DeterministicallyPicksNewest).

Why "at most one active row per PolicyType" is not a hard DB constraint

Unlike PolicyAcceptance's idempotency guard (a genuinely high-concurrency table - many customers accepting concurrently), PolicyRegistry rows are only ever created by an operator/seed action - low-frequency, not customer-facing traffic. The invariant is maintained procedurally: IPolicyRegistryRepository.DeactivateCurrentActiveAsync(policyType) (an atomic conditional ExecuteUpdateAsync) is called immediately before activating a new version, in the same transaction - mirroring Seller Settlements's own precedent of enforcing an analogous "operator-driven, low-concurrency" invariant procedurally rather than via a MySQL generated-column-plus-unique-index trick. GetCurrentActiveAsync's deterministic tie-break is the safety net if this invariant is ever violated by a bug or manual DB edit.

Reacceptance evaluation

IPolicyComplianceService.GetOutstandingRequiredPoliciesAsync(userId, sellerId, requiredTypes), per required type:

  1. Resolve current active policy (fails closed - PolicyNotConfiguredException - if none).
  2. If the subject has accepted THIS EXACT PolicyRegistryId → compliant.
  3. Otherwise, if the current version's RequiresReacceptance=false → compliant if the subject has accepted any prior version of this PolicyType (a non-material revision, e.g. a typo fix, does not force a fresh explicit action - "policy not requiring reacceptance behaves as documented").
  4. Otherwise (no acceptance at all, or RequiresReacceptance=true and only an old version was accepted) → outstanding. "Old version cannot satisfy new reacceptance requirement" is exactly this branch when RequiresReacceptance=true.

Enforcement checkpoints

Registration

RegisterCommand.AcceptedPolicies (bool, defaults false - never pre-checked). AuthController.Register populates IpAddress/UserAgent from the live request via a record with expression, overwriting anything a client's JSON body might have set for those fields. RegisterCommandHandler:

  1. !cmd.AcceptedPolicies → PolicyAcceptanceNotProvidedException (400) before any user is created.
  2. User creation + both RecordAcceptanceAsync calls (CustomerTerms, PrivacyPolicy) share one IUnitOfWork.BeginTransactionAsync transaction. "Failure to persist acceptance fails registration safely": an unconfigured policy or an unexpected write failure rolls back the whole registration, including the just-created User row - proven directly (Register_PolicyNotConfigured_FailsClosed_RollsBackUserCreation).

Checkout

CreateRazorpayOrderCommandHandler.Handle checks CustomerTerms + ReturnRefundPolicy (the tranche brief's stated minimum) via GetOutstandingRequiredPoliciesAsync, as the very first statement - before any cart/reservation read or Razorpay API call. Outstanding policies throw PolicyAcceptanceRequiredException, mapped by ExceptionHandlingMiddleware to a 409 with a structured details.outstandingPolicies array (policyType, policyRegistryId, version per entry) - the required "structured response," not just a flat error string, so the frontend can render exactly which policy/version still needs acceptance without a second round trip.

IPolicyComplianceService is a mandatory constructor parameter on CreateRazorpayOrderCommandHandler (PR #30 hardening - see "Checkout dependency: mandatory, not optional" below for why this changed from the original optional/nullable design).

Seller onboarding

SellersController.Onboard requires SellerOnboardingRequest.AcceptedSellerAgreement AND AcceptedSellerPrivacyNotice (both bool, default false) before advancing past the point where KYC is submitted (Seller.Create + MarkKycSubmitted) - a 400 POLICY_ACCEPTANCE_NOT_PROVIDED otherwise. Seller/Warehouse creation and both acceptance writes now share one explicit transaction (an incidental atomicity improvement over the pre-LEG-01 code, which called SaveChangesAsync once with no transaction wrapper at all).

Checkout dependency: mandatory, not optional (PR #30 hardening)

The original LEG-01 PR made IPolicyComplianceService optional/nullable on CreateRazorpayOrderCommandHandler (= null default, if (_policyCompliance is not null) {...}), mirroring this codebase's ICouponRepository?/INotificationService? convention - a pragmatic trade-off to avoid touching the ~14 pre-existing test files that construct the handler directly for unrelated coupon/inventory/notification/settlement mechanics. A P1 review finding on PR #30 correctly flagged this as a real risk: a production compliance gate should not be structurally bypassable merely because the dependency was omitted - a nullable parameter with a silent-skip if (_policyCompliance is not null) guard means a future refactor, a missed DI registration, or any code path that constructs the handler without that one argument silently disables checkout policy enforcement entirely, with no error, no log line, nothing.

Fixed by making policyCompliance genuinely mandatory (moved ahead of the still-optional coupons parameter, since C# forbids a required parameter after one with a default value - DI resolution is unaffected, Microsoft.Extensions.DependencyInjection resolves constructor parameters by type, never by position). Handle no longer has an is not null guard at all - the policy check is now an unconditional first statement. Proven two ways:

  • CreateRazorpayOrderCommandHandler_PolicyComplianceParameter_IsMandatory_NoDefaultValue - reflects on the constructor and asserts the IPolicyComplianceService parameter has no default value and is not optional (a compile-time guarantee; this test exists so the guarantee can never silently regress back to optional/nullable without a failing test).
  • ProductionDependencyInjectionRegistration_AlwaysResolvesNonNullPolicyCompliance - registers IPolicyComplianceService and its two dependencies with the exact concrete types DependencyInjection.cs uses and resolves it through a real container, exactly the way MediatR resolves a handler's constructor dependencies in the real API (never a hand-written new).

The ~15 pre-existing test files that construct the handler directly for unrelated mechanics now pass PolicyTestSupport.AlwaysCompliant - a stateless fake IPolicyComplianceService whose GetOutstandingRequiredPoliciesAsync always returns an empty list - instead of seeding and accepting CustomerTerms/ReturnRefundPolicy for every customer they create just to unblock a test whose real subject is something else entirely. This keeps blast radius to one mechanical argument addition per call site rather than sprinkling policy-seeding/acceptance boilerplate through files that have nothing to do with LEG-01.

Security / integrity

  • Never trust client-supplied hash/version - see "Server authority" above.
  • SHA-256 document identity - PolicyDocumentHasher.ComputeHash (Application/Features/ Policies/PolicyComplianceFeatures.cs), mirroring RazorpayWebhookSignature. ComputePayloadHash/MessageDeliveryReceiptHashing.ComputePayloadHash's exact convention elsewhere in this codebase.
  • Deterministic effective-policy selection - see above.
  • Duplicate submissions idempotent - IPolicyAcceptanceRepository.TryRecordAsync catches the unique-index violation and returns false, never throwing to the caller; RecordAcceptanceAsync surfaces this as AlreadyAccepted=true, a harmless no-op.
  • Concurrent duplicate acceptance produces one logical acceptance - proven under real MySQL/ InnoDB, not only SQLite - see "MySQL nullable unique-index semantics" and "Concurrency evidence."

Checkout 409 response JSON casing (PR #30 P1 fix)

ExceptionHandlingMiddleware.HandleExceptionAsync serializes its response with a raw JsonSerializer.Serialize(new {...}) call - unlike every MVC controller response (which goes through Ok(...) and picks up AddControllers()'s default camelCase PropertyNamingPolicy automatically), this call passes no JsonSerializerOptions at all. The original outstandingPolicies projection used o.Version member-access shorthand, which - under default System.Text.Json options with no naming policy - serializes as the PascalCase key "Version", not "version" like its sibling properties (policyType, policyRegistryId), which were explicitly named in lower camelCase. A frontend reading details.outstandingPolicies[i] .version (per this endpoint's own documented contract) got undefined for every entry - exactly the "checkout can now return structured 409 PolicyAcceptanceRequired, but the current frontend does not present/resolve outstanding policies" class of integration blocker PR #30 exists to close.

Fixed by extracting ExceptionHandlingMiddleware.BuildDetails(Exception) (now public static, a deliberate test seam - see Program.RunMigrationOnlyAsync/ValidateRuntimeConfiguration for the identical existing convention of public static test-seam methods on API-layer types) and naming every property explicitly in lower camelCase, so the output no longer depends on which serialization path is used. ExceptionHandlingMiddlewareDetailsTests.cs serializes the built details object with the exact same (no-options) call the middleware itself uses and asserts the key is "version", not "Version" - a regression test for the actual wire format, not just the C# member names.

MySQL nullable unique-index semantics

PolicyAcceptance.UserId/.SellerId are both nullable (exactly one is ever set). MySQL - like every SQL-standard engine - treats each NULL in a unique index as distinct from every other NULL, including within one composite key's NULL component. A naive unique index on (PolicyRegistryId, UserId, SellerId) would therefore never actually constrain anything: every customer row has SellerId=NULL and every seller row has UserId=NULL, so two rows for the exact same subject and exact same policy version would never collide - the very "concurrent duplicate acceptance" race this table exists to prevent would go completely unenforced by such an index.

The fix: PolicyAcceptance.SubjectKey is a non-nullable, always-populated computed string ("user:{guid}" or "seller:{guid}"), set once at Create time. The real unique index is (PolicyRegistryId, SubjectKey) - both columns are always non-null, so MySQL's standard (non-NULL) uniqueness semantics apply exactly as expected. See MySqlPolicyAcceptanceLedgerConcurrencyTests.cs for the real-InnoDB proof this actually works for both the customer and seller side of the split, and that it does not over-constrain (two different real subjects accepting the same policy concurrently both succeed).

Seeding

DbSeeder.SeedPolicyRegistryAsync (dev/test only - gated by the pre-existing Database:SeedOnStartup, itself already Development-only by default) seeds one active PolicyRegistry row per PolicyType, Version="v0.1-DRAFT" (matching the draft documents' own stated version). Deliberately does not read docs/legal/*.md content or compute a hash from it - see the method's own doc comment for the two reasons (path-resolution fragility across local dev/Docker/CI, and not wanting to treat an unreviewed draft's text as if a "computed at publication" hash had already been produced). The seeded DocumentHash is PolicyDocumentHasher.ComputeHash of a fixed, clearly-synthetic marker string - a real SHA-256 value (the hashing mechanism itself is exercised for real), just not derived from real document content. The real publication flow (once India legal review clears a document) is an operator/admin action: compute the hash of the actual approved final text, then IPolicyRegistryRepository.DeactivateCurrentActiveAsync + AddAsync the real version/hash/ effective date in one transaction - never automatic.

docs/legal/*.md itself is committed in this PR (previously untracked - see "Purpose" above for why) with its content completely unchanged, DRAFT banners intact.

Staging bootstrap (PR #30 P1 fix)

A P1 review finding on PR #30 correctly flagged that non-development environments fail closed when a required active policy row is missing (PolicyNotConfiguredException, 409), while nothing in that PR gave staging a safe way to get itself out of that state - only DbSeeder's Database:SeedOnStartup-gated dev/test seeding (Development-only by default) populated PolicyRegistry at all. "Do NOT use direct manual SQL as the expected deployment process" ruled out the obvious workaround.

PolicyRegistryBootstrapper (Infrastructure/Data/Seeding/PolicyRegistryBootstrapper.cs) is the dedicated, deterministic, idempotent, auditable mechanism this closes the gap with, invoked via a new CLI command:

dotnet ECommerce.API.dll --bootstrap-policy-registry [--policy-package /path/to/manifest.json]

Wired into Program.cs exactly like the pre-existing --migrate-only command (same RunMigrationOnlyAsync/RunBootstrapPolicyRegistryAsync pattern: a minimal, DI-light service collection built from configuration, injectable for tests via serviceCollectionFactory) - it is a standalone one-off command, not wired into any Kubernetes/Helm pre-upgrade migration hook.

Precision note (superseded by the shared-VPS Docker Compose staging architecture): the statement above was accurate for the Kubernetes/Helm deployment path this section originally described, but is no longer true of the current staging path. scripts/deploy-staging-vps.sh (the canonical staging runbook - see Deployment → Staging (VPS)) invokes --bootstrap-policy-registry automatically, with no --policy-package, on every staging deploy (its Step 6, immediately after schema migration). That is intentional and safe only because no --policy-package means the built-in draft placeholder package is published, which is the one and only invocation Production categorically refuses outright (see "Production refuses placeholders two independent ways" below) - it is not a generally-safe-to-automate command. Do not wire a real --policy-package manifest into any automated deploy path; publishing real, India-legal-approved policy content remains a deliberate, explicit, manual operator action, in staging and production alike.

With no --policy-package: publishes PolicyRegistryBootstrapper.BuildDefaultDraftPackage() - the exact same placeholder content/hash DbSeeder.SeedPolicyRegistryAsync already used (that method is now a thin wrapper calling this same function), so the CLI tool and the dev/test seeder stay byte-for-byte convergent. Categorically refused in Production - see below.

With --policy-package <manifest.json>: the manifest is a small JSON document naming one real document file per PolicyType plus an explicit "approved": true attestation:

{
  "approved": true,
  "policies": [
    {
      "policyType": "CustomerTerms",
      "documentPath": "customer-terms-final.md",
      "version": "v1.0-FINAL",
      "effectiveDate": "2026-10-01T00:00:00Z",
      "requiresReacceptance": true
    }
  ]
}

documentPath entries resolve relative to the manifest's own directory, so a manifest plus its document files can be mounted/copied together as one unit (e.g. a ConfigMap volume) without absolute paths baked in.

Idempotent: for each policy in the package, the tool computes the document's SHA-256 hash itself - never a caller-supplied hash - and checks whether a PolicyRegistry row with that exact (PolicyType, DocumentHash) already exists. If so, it is a no-op (Published: false); nothing is written. Only when the content genuinely differs from what is already on file does it deactivate the current active row for that type and insert a new one (the same procedural DeactivateCurrentActiveAsync-style invariant used everywhere else in this design). Running the exact same package twice therefore produces zero new rows on the second run - proven by RunAsync_CalledTwiceWithSamePackage_SecondCallPublishesNothingNew and the CLI-level NonProduction_CalledTwice_IsIdempotent_ViaTheCliEntryPointItself.

Production refuses placeholders two independent ways, not by trusting one flag alone:

  1. The manifest's own attestation - Production requires a manifest whose top-level approved field is true. No manifest at all (the built-in draft package) is refused outright.
  2. Content-based marker scan - even with approved: true, every document's content is scanned for known draft/placeholder markers ("NOT APPROVED FOR PRODUCTION", "TO BE COMPUTED AT PUBLICATION", "TO BE SET AFTER LEGAL REVIEW", "PLACEHOLDER-SEED-NOT-REAL-CONTENT" - the exact banner/metadata-table text every docs/legal/*.md file carries today). If any marker is found, the whole run fails before anything is written - a mislabeled or fat-fingered manifest cannot push draft text live. This never marks a draft legal document as approved on the tool's own authority; approved is an operator attestation the tool trusts only after this independent check also passes.

See PolicyRegistryBootstrapperTests.cs (direct RunAsync/LoadManifestAsync coverage) and BootstrapPolicyRegistryCliTests.cs (the Program.RunBootstrapPolicyRegistryAsync CLI/config layer on top of it) for the full test matrix - fresh-database bootstrap, idempotency, Production rejecting both a missing manifest and an unapproved one, Production rejecting draft-marked content even when approved: true, and a genuinely clean approved package succeeding in Production.

Frontend integration (PR #30 hardening)

The original LEG-01 PR was API/backend only; PR #30 closes the resulting "changed API contracts, no corresponding frontend" P1 finding with four pieces of UI, all in frontend/src:

  • policiesApi (services/api/index.js) - thin wrappers over GET /policies/current, GET /policies/outstanding, POST /policies/accept.
  • Checkbox/PolicyDraftBanner (components/common/index.jsx) - a shared, never-pre-checked checkbox component and the exact docs/legal/*.md "DRAFT — NOT APPROVED FOR PRODUCTION — REQUIRES INDIA LEGAL REVIEW" banner text, reused everywhere policy acceptance is surfaced so staging/early environments never imply production approval.
  • LegalPolicyPage (pages/Legal/LegalPolicyPage.jsx, route /legal/:policyType) - a real, functioning target for the "Customer Terms link"/"Privacy Policy link" requirement (previously href="#" placeholders). Shows the server-authoritative version/effective-date/hash from GET /policies/current plus the draft banner - it does not render the actual legal text (docs/legal/*.md is not served by any API and stays an engineering/legal-review artifact).
  • utils/policyCheckout.js - the acceptance/reacceptance decision logic extracted out of JSX so it is unit-testable without a DOM/component-rendering harness (this project's test runner is plain node --test, no jsdom/React Testing Library - see src/auth-hardening.test.js and src/routes/authRouteState.js for the established "extract the logic, test it directly" convention this follows). buildRegisterPayload/buildSellerOnboardingPayload throw unless acceptance was explicitly given; attemptCreateOrder/acceptOutstandingPolicies/ buildInitialPolicyChecks implement the checkout state machine below. RegisterPage.jsx, SellerRegisterPage.jsx, SellerOnboardingPage.jsx, and CheckoutPage.jsx all call these functions directly, not a parallel reimplementation.

Registration (RegisterPage.jsx/SellerRegisterPage.jsx) - an acceptedPolicies checkbox, useState(false) (never pre-checked), linking to /legal/CustomerTerms and /legal/PrivacyPolicy (opened in a new tab so the form isn't lost), with the draft banner directly above it. The submit button is disabled until checked, and handleSubmit calls buildRegisterPayload(form, acceptedPolicies) before dispatching register(...) - unaccepted submission never reaches the network at all (proven by registration without acceptance never reaches the network - the API call is never made).

Checkout reacceptance: no automatic retry (P1 fix, second hardening pass)

A follow-up review found the first hardening pass's checkout flow still auto-resumed payment immediately after policy acceptance (acceptOutstandingPoliciesAndRetry re-invoked createOrderAndOpenCheckout, which on success opened Razorpay with no further user action) - a locked requirement was that policy acceptance and starting a payment must be two structurally separate user actions, never one flowing automatically into the other. Fixed by making the invariant structural, not just behavioral:

  • attemptCreateOrder (unchanged in shape) still calls paymentApi.createRazorpayOrder exactly once per invocation and branches three ways: success opens the Razorpay popup (openRazorpayCheckout), a 409 POLICY_ACCEPTANCE_REQUIRED response calls onOutstandingPolicies with the structured list and stops (no reservation exists yet since the server rejects before creating one), anything else goes through the pre-existing generic error handling. createOrderAndOpenCheckout (the function handlePayment's explicit Pay click calls) is the only caller.
  • acceptOutstandingPolicies({ outstandingPolicies, acceptFn }) replaces acceptOutstandingPoliciesAndRetry - it has no retryFn parameter at all. It calls POST /policies/accept once per outstanding policy, in order, stopping at the first failure ("partial acceptance failure does not continue"), and returns. It has no reference to payment or order creation anywhere in its implementation - there is structurally nothing for a caller to wire up that would resume checkout, unlike the old signature which took a retryFn a caller could (and did) point at createOrderAndOpenCheckout.
  • CheckoutPage.handleConfirmPolicyAcceptance calls acceptOutstandingPolicies and, on success, closes the modal, clears all policy-modal state (outstandingPolicies, policyAcceptedChecks), sets checkout loading back to false, and shows - both as a toast and via the page's existing accessible FormAnnouncer (aria-live="assertive") - "Policies accepted. Click Pay to continue.". It does not call createOrderAndOpenCheckout or anything that reaches openRazorpayCheckout. The ordinary Pay button (unchanged, still handlePayment on form submit) is simply available again for the customer's next explicit click - which starts an entirely fresh attemptCreateOrder invocation (call #2, re-running the server-side policy check as part of creating the order; the client never assumes acceptance alone is sufficient). On acceptance failure, the modal stays open with whatever the customer had checked preserved, a clear error is shown, and no order/payment state is touched.
  • buildInitialPolicyChecks(outstandingPolicies) - extracted so "every checkbox begins unchecked" when the modal first opens is a directly testable function, not an inline assumption.

The full locked flow - Pay -> 409 -> modal opens (all unchecked) -> confirm disabled until all checked -> accept (create-order count stays 1, Razorpay never opens) -> modal closes, "click Pay to continue" -> explicit second Pay -> create-order call #2 -> success -> Razorpay opens exactly once - is proven end-to-end by full locked flow: Pay -> 409 -> accept policies -> STOP (no auto-retry) -> explicit second Pay -> success in policy-checkout-hardening.test.js.

Seller onboarding (SellerOnboardingPage.jsx) - two independent checkboxes (acceptedSellerAgreement, acceptedSellerPrivacyNotice), neither pre-selected, linking to /legal/SellerAgreement and /legal/SellerPrivacyNotice. handleSubmit calls buildSellerOnboardingPayload(form, acceptedSellerAgreement, acceptedSellerPrivacyNotice), which throws unless both are true - accepting only one is insufficient, matching the backend's SellerOnboardingRequest contract exactly.

Tests

  • PolicyAcceptanceLedgerTests.cs (SQLite, 28 tests) - domain entity invariants; deterministic current-policy selection (not-configured, inactive-only, future-dated, multiple-active tie-break); server-authoritative recording (exact version/hash persisted, spoofing structurally impossible at both the service and API-contract layers, duplicate idempotent); reacceptance evaluation (never-accepted, current-accepted, old-satisfies-when-not-required, old-does-not- satisfy-when-required); registration enforcement (rejected without acceptance, succeeds with it, fails closed and rolls back on missing policy config); checkout enforcement (structured outstanding-policy response, no reservation created, succeeds when compliant); seller onboarding enforcement (blocked without both agreements, succeeds and persists both acceptances with them); the mandatory-dependency proof pair described in "Checkout dependency: mandatory, not optional" above (replaces the original tranche's now-obsolete "backward compatible when optional" test).
  • MySqlPolicyAcceptanceLedgerConcurrencyTests.cs ([Trait("Category","mysql-smoke")], MYSQL_SMOKE_CONNSTR-gated, [Collection("MySqlSmoke")], 3 tests) - two concurrent duplicate customer acceptances (exactly one succeeds); two concurrent duplicate seller acceptances (exactly one succeeds - the specific case the SubjectKey design exists for); two different real subjects accepting the same policy concurrently (both succeed - the index does not over-constrain).
  • ExceptionHandlingMiddlewareDetailsTests.cs (3 tests, new) - proves the checkout 409 JSON casing fix (see "Checkout 409 response JSON casing" above) at the actual serialized-wire level.
  • PolicyRegistryBootstrapperTests.cs (16 tests, new) - direct PolicyRegistryBootstrapper .RunAsync/LoadManifestAsync coverage: fresh-database bootstrap, convergence with DbSeeder's hash, idempotency (identical rerun, identical-content manifest rerun), version supersession on genuinely changed content, Production rejecting a missing manifest / an unapproved manifest / draft-marked content even when approved, a clean approved package succeeding in Production, DocumentPath resolution via the injectable reader seam, manifest loading/validation.
  • BootstrapPolicyRegistryCliTests.cs (6 tests, new) - the Program .RunBootstrapPolicyRegistryAsync CLI/config layer (missing/placeholder connection string, successful non-production bootstrap, CLI-level idempotency, Production rejection, an approved manifest publishing from real files on disk) - mirrors MigrationOnlyAndRuntimeValidationTests.cs's exact testability pattern for --migrate-only.
  • policy-checkout-hardening.test.js (frontend, 22 node --test tests) - registration starts unchecked / stays blocked without acceptance / never reaches the network unaccepted / sends the correct contract when accepted; seller onboarding requires both explicit acceptances independently; checkout 409 is detected and surfaces the outstanding list without opening payment; non-policy checkout errors are unaffected; every checkbox begins unchecked and confirm stays disabled until all are checked; accepting outstanding policies calls the acceptance API once per policy and stops at the first failure; acceptOutstandingPolicies is structurally incapable of triggering a retry (no retryFn parameter exists to wire up); and the full locked flow end-to-end - initial Pay causes exactly one create-order call, accepting policies after a 409 leaves that count at exactly 1 and Razorpay unopened, and only an explicit second Pay click causes create-order call #2 and opens Razorpay.

Validation performed

Original LEG-01 tranche: dotnet build clean; full default suite 898/898 (872 baseline + 26 new); policy-focused filter 31/31; mysql-smoke 29/29 (26 baseline + 3 new); migration AddPolicyAcceptanceLedger applied cleanly from empty via dotnet ef database update, no pending model changes; git diff --check clean; mkdocs build --strict exit 0.

PR #30 hardening, first pass (frontend integration, staging bootstrap, mandatory checkout dependency, JSON casing fix):

  • dotnet build ECommerce.sln --configuration Release - 0 warnings, 0 errors.
  • Full default backend suite (--filter "Category!=mysql-smoke") - 922/922 passed. 899 of those are the original-tranche baseline (898 + a net +1 in PolicyAcceptanceLedgerTests.cs - one now-obsolete backward-compatible-skip test removed, two mandatory-dependency proof tests added, see "Checkout dependency: mandatory, not optional" above); the other 23 are new this pass: ExceptionHandlingMiddlewareDetailsTests.cs (3), PolicyRegistryBootstrapperTests.cs (14), BootstrapPolicyRegistryCliTests.cs (6).
  • Policy/compliance-focused filter - 58/58 passed (55 in the default run + 3 mysql-smoke).
  • No new EF migration - no entity/schema changes; confirmed via dotnet ef migrations has-pending-model-changes -> "No changes have been made to the model since the last migration."
  • Real-MySQL concurrency suite (--filter "Category=mysql-smoke", disposable freshly-migrated container) - 29/29 passed (unchanged from the original tranche - this pass added no new mysql-smoke tests, but the two ripple-affected mysql-smoke files (MySqlInventoryReservationConcurrencyTests.cs, MySqlSettlementRefundConcurrencyTests.cs, both updated for the now-mandatory IPolicyComplianceService parameter) were re-verified against real InnoDB, not just SQLite).
  • Frontend: npm run build clean; npm run lint clean (0 errors/warnings); npm test - 32/32 passed (13 pre-existing auth-hardening.test.js + 19 new policy-checkout-hardening.test.js).
  • git diff --check clean; mkdocs build --strict exit 0.

PR #30 hardening, second pass (no-automatic-checkout-retry fix - frontend/docs only, no backend files changed):

  • Backend re-verified unaffected: dotnet build clean; full default suite 922/922 (unchanged); mysql-smoke 29/29 (unchanged, re-run against a fresh disposable freshly-migrated container); dotnet ef migrations has-pending-model-changes still confirms none.
  • Frontend: npm run build clean; npm run lint clean (0 errors/warnings); npm test - 35/35 passed (13 pre-existing + 22 in policy-checkout-hardening.test.js, up from 19 - three net new: acceptOutstandingPoliciesAndRetry's old tests replaced/expanded with acceptOutstandingPolicies/buildInitialPolicyChecks coverage plus the new full-locked-flow end-to-end test).
  • git diff --check clean; mkdocs build --strict exit 0.

Known limitations

  1. ~~IPolicyComplianceService optional/nullable on CreateRazorpayOrderCommandHandler~~ - resolved in PR #30 (P1 fix). Now mandatory - see "Checkout dependency: mandatory, not optional" above.
  2. No admin CRUD endpoint for PolicyRegistry - activating a new policy version via an HTTP admin UI is still not built. Partially addressed in PR #30: the --bootstrap-policy-registry CLI command (see "Staging bootstrap" above) gives operators a deterministic, auditable way to publish a real approved policy package without either a manual SQL statement or a dedicated admin UI - but it is still a CLI action, not a web admin screen. IPolicyRegistryRepository.AddAsync/DeactivateCurrentActiveAsync remain the primitives an admin endpoint would be built on, whenever one is.
  3. GetOutstandingRequiredPoliciesAsync's per-type resolution is N sequential repository round trips (one GetCurrentActiveAsync + one or two GetAcceptedPolicyRegistryIdsAsync calls per required type), not a single batched query - acceptable at LEG-01's scale (checkout checks exactly 2 types; registration/onboarding check exactly 2 each) but would need batching if a much longer required-policy list were ever introduced. Unchanged by PR #30.
  4. ~~No frontend UI work~~ - resolved in PR #30 - see "Frontend integration" above.
  5. LegalPolicyPage shows only version/hash/effective-date metadata, not the actual legal text (new in PR #30, by design) - docs/legal/*.md is not served by any API. A customer/ seller clicking "Customer Terms"/"Seller Agreement" etc. sees the server-authoritative version/hash and the draft banner, not the document body. Rendering the real text (once it exists in a form fit to publish) would need either a new "serve published policy content" endpoint or a CMS-style content pipeline - out of scope here.

Remaining LEG-02+ work (explicitly out of scope for this PR)

  • LEG-02 - product statutory fields (e.g., country of origin, manufacturer details, importer details per Legal Metrology / Consumer Protection e-commerce rules) - not touched.
  • LEG-03 - seller KYC document/bank-account/licence collection beyond what Seller already has (GSTIN/PAN fields) - not touched. seller_bank_accounts/seller_documents/ seller_licences/category_compliance_configs/seller_listing_compliance from docs/legal/ENFORCEMENT_GAP_MATRIX.md's own proposal are not built here.
  • Grievance subsystem - GrievancePolicy is registered as a PolicyType (so it can be tracked in the ledger like any other policy) but no grievance ticket/message/audit-log workflow (grievances/grievance_messages/grievance_audit_log in the gap matrix) is built.
  • DPDP deletion workflow - a genuine account/data-deletion request flow, and any real use of PolicyAcceptance.Withdraw, are not implemented.
  • Admin web UI for policy-registry management - see "Known limitations" #2; the --bootstrap-policy-registry CLI command (PR #30) covers the deterministic/auditable publication need, but there is still no browser-based admin screen for it.
  • ~~Real document-hash computation tooling~~ - resolved in PR #30: PolicyRegistryBootstrapper computes the SHA-256 hash itself from real document content supplied via a --policy-package manifest, refuses to run in Production without an explicitly approved package, and independently refuses any document whose content still carries a draft/placeholder marker. What remains open is purely organisational: the actual India legal review sign-off process itself, and who runs the CLI command once it's cleared - not anything technical.