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-registrystaging/production publication tool ("Staging bootstrap" below), a mandatory (no longer optional/nullable)IPolicyComplianceServiceon 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=truewith a futureEffectiveDate- 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:
- Resolve current active policy (fails closed -
PolicyNotConfiguredException- if none). - If the subject has accepted THIS EXACT
PolicyRegistryId→ compliant. - Otherwise, if the current version's
RequiresReacceptance=false→ compliant if the subject has accepted any prior version of thisPolicyType(a non-material revision, e.g. a typo fix, does not force a fresh explicit action - "policy not requiring reacceptance behaves as documented"). - Otherwise (no acceptance at all, or
RequiresReacceptance=trueand only an old version was accepted) → outstanding. "Old version cannot satisfy new reacceptance requirement" is exactly this branch whenRequiresReacceptance=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:
!cmd.AcceptedPolicies→PolicyAcceptanceNotProvidedException(400) before any user is created.- User creation + both
RecordAcceptanceAsynccalls (CustomerTerms,PrivacyPolicy) share oneIUnitOfWork.BeginTransactionAsynctransaction. "Failure to persist acceptance fails registration safely": an unconfigured policy or an unexpected write failure rolls back the whole registration, including the just-createdUserrow - 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 theIPolicyComplianceServiceparameter 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- registersIPolicyComplianceServiceand its two dependencies with the exact concrete typesDependencyInjection.csuses and resolves it through a real container, exactly the way MediatR resolves a handler's constructor dependencies in the real API (never a hand-writtennew).
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), mirroringRazorpayWebhookSignature. ComputePayloadHash/MessageDeliveryReceiptHashing.ComputePayloadHash's exact convention elsewhere in this codebase. - Deterministic effective-policy selection - see above.
- Duplicate submissions idempotent -
IPolicyAcceptanceRepository.TryRecordAsynccatches the unique-index violation and returnsfalse, never throwing to the caller;RecordAcceptanceAsyncsurfaces this asAlreadyAccepted=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:
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:
- The manifest's own attestation - Production requires a manifest whose top-level
approvedfield istrue. No manifest at all (the built-in draft package) is refused outright. - 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 everydocs/legal/*.mdfile 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;approvedis 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 overGET /policies/current,GET /policies/outstanding,POST /policies/accept.Checkbox/PolicyDraftBanner(components/common/index.jsx) - a shared, never-pre-checked checkbox component and the exactdocs/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 (previouslyhref="#"placeholders). Shows the server-authoritative version/effective-date/hash fromGET /policies/currentplus the draft banner - it does not render the actual legal text (docs/legal/*.mdis 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 plainnode --test, no jsdom/React Testing Library - seesrc/auth-hardening.test.jsandsrc/routes/authRouteState.jsfor the established "extract the logic, test it directly" convention this follows).buildRegisterPayload/buildSellerOnboardingPayloadthrow unless acceptance was explicitly given;attemptCreateOrder/acceptOutstandingPolicies/buildInitialPolicyChecksimplement the checkout state machine below.RegisterPage.jsx,SellerRegisterPage.jsx,SellerOnboardingPage.jsx, andCheckoutPage.jsxall 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 callspaymentApi.createRazorpayOrderexactly once per invocation and branches three ways: success opens the Razorpay popup (openRazorpayCheckout), a409 POLICY_ACCEPTANCE_REQUIREDresponse callsonOutstandingPolicieswith 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 functionhandlePayment's explicit Pay click calls) is the only caller.acceptOutstandingPolicies({ outstandingPolicies, acceptFn })replacesacceptOutstandingPoliciesAndRetry- it has noretryFnparameter at all. It callsPOST /policies/acceptonce 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 aretryFna caller could (and did) point atcreateOrderAndOpenCheckout.CheckoutPage.handleConfirmPolicyAcceptancecallsacceptOutstandingPoliciesand, on success, closes the modal, clears all policy-modal state (outstandingPolicies,policyAcceptedChecks), sets checkoutloadingback tofalse, and shows - both as a toast and via the page's existing accessibleFormAnnouncer(aria-live="assertive") - "Policies accepted. Click Pay to continue.". It does not callcreateOrderAndOpenCheckoutor anything that reachesopenRazorpayCheckout. The ordinary Pay button (unchanged, stillhandlePaymenton form submit) is simply available again for the customer's next explicit click - which starts an entirely freshattemptCreateOrderinvocation (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) - directPolicyRegistryBootstrapper .RunAsync/LoadManifestAsynccoverage: fresh-database bootstrap, convergence withDbSeeder'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,DocumentPathresolution via the injectable reader seam, manifest loading/validation.BootstrapPolicyRegistryCliTests.cs(6 tests, new) - theProgram .RunBootstrapPolicyRegistryAsyncCLI/config layer (missing/placeholder connection string, successful non-production bootstrap, CLI-level idempotency, Production rejection, an approved manifest publishing from real files on disk) - mirrorsMigrationOnlyAndRuntimeValidationTests.cs's exact testability pattern for--migrate-only.policy-checkout-hardening.test.js(frontend, 22node --testtests) - 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;acceptOutstandingPoliciesis structurally incapable of triggering a retry (noretryFnparameter 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 inPolicyAcceptanceLedgerTests.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-mandatoryIPolicyComplianceServiceparameter) were re-verified against real InnoDB, not just SQLite). - Frontend:
npm run buildclean;npm run lintclean (0 errors/warnings);npm test- 32/32 passed (13 pre-existingauth-hardening.test.js+ 19 newpolicy-checkout-hardening.test.js). git diff --checkclean;mkdocs build --strictexit 0.
PR #30 hardening, second pass (no-automatic-checkout-retry fix - frontend/docs only, no backend files changed):
- Backend re-verified unaffected:
dotnet buildclean; 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-changesstill confirms none. - Frontend:
npm run buildclean;npm run lintclean (0 errors/warnings);npm test- 35/35 passed (13 pre-existing + 22 inpolicy-checkout-hardening.test.js, up from 19 - three net new:acceptOutstandingPoliciesAndRetry's old tests replaced/expanded withacceptOutstandingPolicies/buildInitialPolicyCheckscoverage plus the new full-locked-flow end-to-end test). git diff --checkclean;mkdocs build --strictexit 0.
Known limitations¶
- ~~
IPolicyComplianceServiceoptional/nullable onCreateRazorpayOrderCommandHandler~~ - resolved in PR #30 (P1 fix). Now mandatory - see "Checkout dependency: mandatory, not optional" above. - 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-registryCLI 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/DeactivateCurrentActiveAsyncremain the primitives an admin endpoint would be built on, whenever one is. GetOutstandingRequiredPoliciesAsync's per-type resolution is N sequential repository round trips (oneGetCurrentActiveAsync+ one or twoGetAcceptedPolicyRegistryIdsAsynccalls 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.- ~~No frontend UI work~~ - resolved in PR #30 - see "Frontend integration" above.
LegalPolicyPageshows only version/hash/effective-date metadata, not the actual legal text (new in PR #30, by design) -docs/legal/*.mdis 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
Selleralready has (GSTIN/PAN fields) - not touched.seller_bank_accounts/seller_documents/seller_licences/category_compliance_configs/seller_listing_compliancefromdocs/legal/ENFORCEMENT_GAP_MATRIX.md's own proposal are not built here. - Grievance subsystem -
GrievancePolicyis registered as aPolicyType(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_login 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-registryCLI 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:
PolicyRegistryBootstrappercomputes the SHA-256 hash itself from real document content supplied via a--policy-packagemanifest, 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.
Related pages¶
- Policy Acceptance Infrastructure (Work Package G) - superseded design proposal, kept for history.
- Seller Settlements - the "procedural invariant instead of a DB constraint, for a low-concurrency operator-driven table" precedent this design reuses.
- Payment Production Hardening /
WhatsApp + SMS Readiness - the
PayloadHash/idempotent-insert patternPolicyDocumentHasher/IPolicyAcceptanceRepository.TryRecordAsyncmirror.