Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
cbbb8e7
feat: let a mapping refuse a provider's answer and classify failed ch…
nisargabd Oct 5, 2026
3e278a1
feat: add tokenHeader auth for upstreams that log in and read a bare …
nisargabd Oct 5, 2026
227f6ff
feat: add the Grievance plugin with PMFBY init, confirm and status ma…
nisargabd Oct 5, 2026
7a41690
fix: classify null PMFBY messages and refuse a numeric phone before t…
nisargabd Oct 5, 2026
9e09110
fix: stop logging the provider's body when a mapping rejects its answ…
nisargabd Oct 5, 2026
ca1e14d
refactor: rename exchangeQueryToken to exchangeLoginToken [#57]
nisargabd Oct 5, 2026
0ba5b0d
fix: drop null or empty PMFBY status fields instead of failing or map…
nisargabd Oct 5, 2026
2301196
feat: read a binding from the channel when a payload has no contract …
nisargabd Oct 5, 2026
99c62a8
feat: lodge PMFBY grievances through support and answer errors as des…
nisargabd Oct 5, 2026
9932fc2
refactor: fold the mapping refusal into serve.go [#57]
nisargabd Oct 5, 2026
bec3bd2
fix: name the repeated array and correct comments in the PMFBY grieva…
nisargabd Oct 6, 2026
a4846c4
feat: align the PMFBY grievance replies with the updated design and p…
nisargabd Oct 6, 2026
53894ad
feat: move the PMFBY grievance into its grievance and case bands [#57]
nisargabd Oct 7, 2026
4a558da
feat: read a login token from a nested path in the reply [#57]
nisargabd Oct 8, 2026
5809f0e
feat: drop the PMFBY OTP leg and read FGMS's reply envelope as docume…
nisargabd Oct 8, 2026
3141dfc
fix: let other capabilities' support requests pass the fallback and d…
nisargabd Oct 8, 2026
6e390a2
fix: check the PMFBY phone and description and date a lodge once [#57]
nisargabd Oct 8, 2026
7ef95fd
refactor: read a binding key per action instead of through a fallback…
nisargabd Oct 9, 2026
ecfeb0c
refactor: leave a half-set action block to New's path check [#57]
nisargabd Oct 9, 2026
bf032b7
refactor: list binding paths in order instead of per action [#57]
nisargabd Oct 9, 2026
dc29b94
refactor: keep the login exchange's name and comments as they were [#57]
nisargabd Oct 9, 2026
1f8c153
fix: answer the review on PR #70 [#57]
nisargabd Oct 10, 2026
df3fa3a
feat: follow the grievance pack's code-only case status and orderId b…
nisargabd Oct 10, 2026
fa84699
refactor: shorten the grievance plugin's comments and merge splitPath…
nisargabd Oct 12, 2026
a133fa5
fix: read PMFBY's real no-record reply and refuse another ticket's re…
nisargabd Oct 12, 2026
f75a293
fix: default PMFBY's category pair and read enrolmentId from the chan…
nisargabd Oct 13, 2026
9dec9bb
fix: answer no grievance found as a 202 NACK [#57]
nisargabd Oct 13, 2026
0ec04cc
fix: answer PMFBY refusals with its own message and case times in UTC…
nisargabd Oct 14, 2026
9a1bd84
feat: hold a login token until the expiry its reply states [#57]
nisargabd Oct 14, 2026
f6995b2
fix: hold PMFBY's token until validTo, its real expiry [#57]
nisargabd Oct 14, 2026
4c0a7ce
fix: read PMFBY's InsertDateTime as the RFC 3339 time it sends [#57]
nisargabd Oct 14, 2026
7bf896f
fix: answer PMFBY failures in our own words, its own faults as 502 [#57]
nisargabd Oct 16, 2026
bc350a1
feat: move the PMFBY grievance onto the Grievance pack and return its…
nisargabd Oct 16, 2026
c604cb0
fix: map Open to Registered and date a lodge by when it was lodged [#57]
nisargabd Oct 16, 2026
3dc3018
refactor: leave required fields to the Grievance pack [#57]
nisargabd Oct 19, 2026
c658a54
refactor: name the PMFBY grievance mappings' constants and variables …
nisargabd Oct 22, 2026
d7b7304
fix: reject a blank PMFBY ticket and echo the trimmed description [#57]
nisargabd Oct 22, 2026
4d44c56
refactor: name the status mapping's empty-object helper omitIfEmpty […
nisargabd Oct 22, 2026
877a6a6
docs: record the PM-KISAN design decisions as ADRs [#60]
KrutikaPhirangi Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,8 @@ test_request.json

# catalogPublish handler's local output root (config/local-beckn-one-bap.yaml)
/catalog/
/docs/
/docs/*
!/docs/adr/
/dev_docs/

# Deployment configs carry real key material and must never be committed.
Expand Down
117 changes: 117 additions & 0 deletions config/mappings/pmfby/grievance.status.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# PMFBY status -> POST /krphapi/FGMS/GetGrievenceTicketsStatus.
# Reads a grievance back by ticket number and phone. Only listed fields are
# returned; the farmer's personal details are dropped.
# Required fields and formats are checked by the Grievance pack.

request: |
(
$attributes := beckn.message.contract.commitments[0].commitmentAttributes;
{
"requestorMobileNo": $attributes.applicantMobile,
"GrievenceSupportTicketNo": $attributes.case.ticketNo
}
)

# Empty PMFBY fields are dropped. No record, or one for another ticket, is a
# 400 in our words; any other failure is a 502. PMFBY's message is logged,
# never returned.
response: |
(
/* Constants */
$PMFBY_SUCCESS_CODE := "1";
$SCHEME := { "code": "PMFBY", "name": "Pradhan Mantri Fasal Bima Yojana" };
$INFORMATION_MODE := "Direct";
$COMMITMENT_STATUS_ACTIVE := "ACTIVE";
$DEFAULT_CASE_STATUS := "UnderReview";
$CASE_STATUS_CODES := { "open": "Registered", "resolved": "Resolved" };
$SEASON_NAMES := { "1": "Kharif", "2": "Rabi", "3": "Zaid" };
$TIMESTAMP_FORMAT := "[Y0001]-[M01]-[D01]T[H01]:[m01]:[s01]Z";

/* e.g. "2026-10-12T11:02:49.000Z" */
$RFC3339_PATTERN := /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$/;
$DIGITS_PATTERN := /^[0-9]+$/;
$YEAR_PATTERN := /^[0-9]{4}$/;

$MSG_NOT_FOUND := "No grievance was found for this ticket number and phone number.";
$MSG_UNAVAILABLE := "PMFBY could not return the grievance status right now. Please try again later.";

$ERROR_REFUSED := { "status": 400, "code": "BIZ_GENERIC_ERROR" };
$ERROR_UNAVAILABLE := { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE" };
/* PMFBY fails everything with responseCode "0", so its wording (lower case)
is the only way to tell a caller mistake from a PMFBY fault. */
$KNOWN_REFUSALS := { "no record found": $MSG_NOT_FOUND };

/* Helpers */
/* PMFBY sends null or "" for missing values. */
$isText := function($value) { $type($value) = "string" and $value != "" };
$asText := function($value) { $isText($value) ? $value };
/* Ids come as numbers or digit strings, and 0 means none. */
$asId := function($value) {
($type($value) = "number" or $type($value) = "string")
and $contains($string($value), $DIGITS_PATTERN) and $number($string($value)) != 0
? $string($value)
};
/* Drops an object with no fields instead of sending {}. */
$omitIfEmpty := function($object) { $exists($keys($object)[0]) ? $object };
$error := function($kind, $message, $cause) {
{ "_error": $merge([$kind, { "message": $message, "cause": $cause }]) }
};

/* Inputs */
$commitment := beckn.message.contract.commitments[0];
$attributes := $commitment.commitmentAttributes;
/* [0] reads a bare record or a list of one alike. */
$record := response.responseDynamic[0];
$pmfbyMessage := $type(response.responseMessage) = "string" ? $trim(response.responseMessage);
$refusalMessage := $pmfbyMessage ? $lookup($KNOWN_REFUSALS, $lowercase($pmfbyMessage));
$isAccepted := $string(response.responseCode) = $PMFBY_SUCCESS_CODE;
/* PMFBY reads by phone alone, so a record for another ticket is no record. */
$isOtherTicket := $exists($record.GrievenceSupportTicketNo)
and $string($record.GrievenceSupportTicketNo) != $string($attributes.case.ticketNo);
$isFound := $isAccepted and ($isText($record.ApplicationNo) or $isText($record.TicketStatus)) and $not($isOtherTicket);
$mappedStatus := $isText($record.TicketStatus) ? $lookup($CASE_STATUS_CODES, $lowercase($trim($record.TicketStatus)));
$caseStatus := $exists($mappedStatus) ? $mappedStatus : $DEFAULT_CASE_STATUS;
$requestYear := $asId($record.RequestYear);
$cropYear := $requestYear and $contains($requestYear, $YEAR_PATTERN) ? $requestYear;
$filedOn := $isText($record.InsertDateTime) and $contains($record.InsertDateTime, $RFC3339_PATTERN)
? $fromMillis($toMillis($record.InsertDateTime), $TIMESTAMP_FORMAT);
/* The officer's answer; "" until there is one. */
$reply := $asText($isText($record.latestRemark) ? $trim($record.latestRemark));

/* Output */
$isFound
? {
"context": $merge([beckn.context, { "action": "on_status", "timestamp": $now() }]),
"message": {
"contract": $merge([beckn.message.contract, {
"commitments": [$merge([$commitment, {
"status": { "descriptor": { "code": $COMMITMENT_STATUS_ACTIVE } },
"commitmentAttributes": {
"@context": $attributes.`@context`,
"@type": $attributes.`@type`,
"informationMode": $INFORMATION_MODE,
"scheme": $SCHEME,
"enrolmentId": $asText($record.ApplicationNo),
"cropYear": $cropYear,
"season": $lookup($SEASON_NAMES, $asId($record.RequestSeason)),
"grievance": {
"category": $omitIfEmpty({ "code": $asId($record.TicketCategoryID), "name": $asText($record.TicketCategoryName) }),
"subCategory": $omitIfEmpty({ "code": $asId($record.TicketSubCategoryID), "name": $asText($record.TicketSubCategoryName) }),
"description": $asText($record.GrievenceDescription)
},
"case": {
"ticketNo": $attributes.case.ticketNo,
"status": { "code": $caseStatus },
"filedOn": $filedOn,
"reply": $reply,
"cropName": $asText($record.CropName)
}
}
}])]
}])
}
}
: $isAccepted ? $error($ERROR_REFUSED, $MSG_NOT_FOUND)
: $exists($refusalMessage) ? $error($ERROR_REFUSED, $refusalMessage, $pmfbyMessage)
: $error($ERROR_UNAVAILABLE, $MSG_UNAVAILABLE, $pmfbyMessage)
)
112 changes: 112 additions & 0 deletions config/mappings/pmfby/grievance.support.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# PMFBY support -> POST /krphapi/FGMS/AddKRPHNCIPGrievenceSupportTicket.
# Files a grievance; no OTP. "Grievence" is PMFBY's spelling.
# Required fields and formats are checked by the Grievance pack.
# The two halves cannot share variables, so the default codes are in both.

request: |
(
/* Constants */
$DEFAULT_CATEGORY_CODE := "3";
$DEFAULT_SUB_CATEGORY_CODE := "10";
$DEFAULT_RECEIPT_SOURCE_ID := "134306";
$SEASON_CODES := { "Kharif": "1", "Rabi": "2", "Zaid": "3" };

$DATE_FORMAT := "[Y0001]-[M01]-[D01]";
$IST_OFFSET := "+0530";

/* Helpers */
$orDefault := function($value, $default) { $exists($value) ? $string($value) : $default };

/* Inputs */
$channel := beckn.message.support.channels[0];
$grievance := $channel.grievance;
$filedAt := $exists($channel.complaintDate) ? $toMillis($channel.complaintDate) : $millis();

/* Output */
{
"requestorMobileNo": $channel.applicantMobile,
"applicationNo": $channel.enrolmentId,
"requestYear": $string($channel.cropYear),
"requestSeason": $lookup($SEASON_CODES, $channel.season),
"ticketCategoryID": $orDefault($grievance.category.code, $DEFAULT_CATEGORY_CODE),
"ticketSubCategoryID": $orDefault($grievance.subCategory.code, $DEFAULT_SUB_CATEGORY_CODE),
"grievenceDescription": $trim($grievance.description),
"complaintDate": $fromMillis($filedAt, $DATE_FORMAT, $IST_OFFSET),
"receiptSourceID": $orDefault($channel.receiptSourceId, $DEFAULT_RECEIPT_SOURCE_ID)
}
)

# Echoes the request and adds the ticket. A known PMFBY refusal is a 400 in our
# words; any other failure is a 502. PMFBY's message is logged, never returned.
response: |
(
/* Constants */
$DEFAULT_CATEGORY_CODE := "3";
$DEFAULT_SUB_CATEGORY_CODE := "10";
$PMFBY_SUCCESS_CODE := "1";
$PROVIDER_NAME := "PMFBY Grievance Portal";
$SCHEME := { "code": "PMFBY", "name": "Pradhan Mantri Fasal Bima Yojana" };
$INFORMATION_MODE := "Direct";
$CASE_STATUS_REGISTERED := "Registered";
$TIMESTAMP_FORMAT := "[Y0001]-[M01]-[D01]T[H01]:[m01]:[s01]Z";

$MSG_PHONE_MISMATCH := "The phone number does not match the one registered for this application number.";
$MSG_APPLICATION_NOT_FOUND := "No PMFBY application was found for this application number.";
$MSG_UNAVAILABLE := "PMFBY could not register the grievance right now. Please try again later.";

$ERROR_REFUSED := { "status": 400, "code": "BIZ_GENERIC_ERROR" };
$ERROR_UNAVAILABLE := { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE" };
/* PMFBY fails everything with responseCode "0", so its wording (lower case)
is the only way to tell a caller mistake from a PMFBY fault. */
$KNOWN_REFUSALS := {
"mobile number not matched": $MSG_PHONE_MISMATCH,
"application details not exists": $MSG_APPLICATION_NOT_FOUND
};

/* Helpers */
$orDefault := function($value, $default) { $exists($value) ? $string($value) : $default };
$error := function($kind, $message, $cause) {
{ "_error": $merge([$kind, { "message": $message, "cause": $cause }]) }
};

/* Inputs */
$channel := beckn.message.support.channels[0];
$grievance := $channel.grievance;
$ticketNo := response.responseDynamic.GrievenceSupportTicketNo;
$pmfbyMessage := $type(response.responseMessage) = "string" ? $trim(response.responseMessage);
$refusalMessage := $pmfbyMessage ? $lookup($KNOWN_REFUSALS, $lowercase($pmfbyMessage));
$hasTicket := $type($ticketNo) = "number" or ($type($ticketNo) = "string" and $ticketNo != "");
/* "Ticket Already Raised" also comes back as success with a ticket. */
$isLodged := $string(response.responseCode) = $PMFBY_SUCCESS_CODE and $hasTicket;

/* Output */
$isLodged
? {
"context": $merge([beckn.context, { "action": "on_support", "timestamp": $now() }]),
"message": {
"support": {
"channels": [{
"@context": $channel.`@context`,
"@type": $channel.`@type`,
"informationMode": $INFORMATION_MODE,
"provider": { "id": $channel.provider.id, "descriptor": { "name": $PROVIDER_NAME } },
"scheme": $SCHEME,
"enrolmentId": $channel.enrolmentId,
"grievance": {
"category": { "code": $orDefault($grievance.category.code, $DEFAULT_CATEGORY_CODE) },
"subCategory": { "code": $orDefault($grievance.subCategory.code, $DEFAULT_SUB_CATEGORY_CODE) },
"description": $trim($grievance.description)
},
"case": {
"ticketNo": $string($ticketNo),
"status": { "code": $CASE_STATUS_REGISTERED },
/* Lodge time, so it agrees with what a status read reports. */
"filedOn": $fromMillis($millis(), $TIMESTAMP_FORMAT)
}
}]
}
}
}
: $exists($refusalMessage) ? $error($ERROR_REFUSED, $refusalMessage, $pmfbyMessage)
: $error($ERROR_UNAVAILABLE, $MSG_UNAVAILABLE, $pmfbyMessage)
)
27 changes: 27 additions & 0 deletions config/provider-adapter.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,32 @@ modules:
# concurrency for this capability.
searchConcurrency: 1

# ------------------------------------------------------------------
# Grievance -- support files a grievance, status reads it back.
# ------------------------------------------------------------------
- id: Grievance
config:
bindingKeys: "pmfby|openagrinet:Grievance"

# Paired by position, tried in order: status names the binding on
# its contract, support on its channel.
providerIdAt: "message.contract.commitments[].offer.provider.id, message.support.channels[].provider.id"
capabilityCodeAt: "message.contract.commitments[].commitmentAttributes.@type, message.support.channels[].@type"

# Logs in at PMFBY's NICUsersLogin and sends the token bare in
# Authorization, held until the login reply's validTo.
# tokenUrl is set per deployment.
pmfby:
authScheme: tokenHeader
tokenUrl: <>
tokenUserField: appAccessUID
tokenUserEnv: PMFBY_ACCESS_UID
tokenSecretField: appAccessPWD
tokenSecretEnv: PMFBY_ACCESS_PWD
tokenResponseField: responseDynamic.token.Token
tokenExpiryField: responseDynamic.token.validTo
headerName: Authorization

# Declaring a step above is not enough: THIS list is what runs. A step
# that appears under providerSteps but not here never executes, and the
# request falls through to the 404 above -- which looks like a registry
Expand All @@ -516,6 +542,7 @@ modules:
- MandiPrice # its binding key, or pass through
- KnowledgeAdvisory # its binding key, or pass through
- AgricultureFacility # its binding key, or pass through
- Grievance # its binding key, or pass through
- signAck # signs whatever the step answered with

# ----------------------------------------------------------------------------
Expand Down
23 changes: 23 additions & 0 deletions docs/adr/0000-template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# ADR-0000 — Title

**Status:** Proposed | Accepted | Superseded by ADR-NNNN
**Date:** YYYY-MM-DD

## Context

What forced a decision. The constraint, not the preamble.

## Decision

What was chosen, stated so that a reader can tell whether a given change
complies with it.

## Alternatives considered

What was rejected, and the specific property that disqualified it. "It was
worse" is not a reason; "it resolves columns by reflection at runtime" is.

## Consequences

What this costs, including the cases it makes harder. An ADR that lists only
benefits is advocacy, not a record.
47 changes: 47 additions & 0 deletions docs/adr/0001-provider-envelopes-in-common.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# ADR-0001 — Provider envelopes live in common, with capability hooks

**Status:** Accepted
**Date:** 2026-10-23

## Context

PM-KISAN does not take plain JSON. Its grievance service takes AES-GCM under a
fixed key and nonce with a service token inside the body; its chatbot (OTP)
service takes AES-128-CBC with a fresh key sent beside the ciphertext. Some
calls must first verify an OTP the farmer received. Mappings are published
files and must stay plain JSON, and must never carry the token.

## Decision

`internal/common/envelope` seals a mapped request and opens the answer:
`aesGcm` (fixed key and nonce, from env vars named in config) and
`aesCbcKeyInBand` (fresh key per request, `cipher@key`). It adds the service
token after the mapping runs. It can verify a challenge on the provider before
the guarded call (`otpVerifyPath`, `otpVerifyActions`), on its own time budget
(`otpVerifyTimeoutMs`). Its HTTP client refuses an off-host redirect, as
common's does.

What only a capability knows comes in as `envelope.Hooks`: where its payload
carries the challenge, and what its provider's "yes" looks like. Settings are a
provider block in the step's config, read by `envelope.Parse`; `envelope:` is
required whenever any `envelope*` setting is present, so a typo cannot send
plaintext.

## Alternatives considered

- **Inside the Grievance plugin.** The next provider with an encrypted
upstream would have to import a package named for another capability or copy
it.
- **The `encrypter`/`decrypter` plugins.** They do X25519 key agreement for
Beckn payloads; PM-KISAN's fixed-key GCM and key-in-band CBC are neither.
- **Encryption in the mapping.** JSONata has no crypto, and the token would
have to be in a public file.

## Consequences

The GCM nonce is fixed because PM-KISAN requires it. With one key and one
nonce, two ciphertexts give the XOR of their plaintexts and a ciphertext can
be forged; this envelope adds no confidentiality or integrity of its own and
TLS is the only protection. A nonce shorter than 12 bytes is refused. The CBC
key travels with the ciphertext, so that envelope is obfuscation; its token is
the only secret.
32 changes: 32 additions & 0 deletions docs/adr/0002-an-action-served-by-another-participant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# ADR-0002 — An action served by another participant is named by its binding key

**Status:** Accepted
**Date:** 2026-10-23

## Context

PM-KISAN grievance's `init` (send the OTP) and the OTP verify before `support`
and `status` are served by the chatbot service, not the grievance service. A
binding has one participant, so one `baseUrl`. The chatbot service is already
a participant in the registry for application status.

## Decision

The provider block names the OTP service by binding key:
`otpBindingKey: "pmkisan-chatbot|openagrinet:SchemeApplication"`.
`common.Config.HostedElsewhere` (provider → action → binding key) sends those
actions to that participant's `baseUrl`, read from the registry per call with
the same active checks as any participant (`common.HostOf`). The verify reads
it the same way. An unpublished binding is a 502.

## Alternatives considered

- **An env var with the host** (`otpBaseUrlEnv`). The address lived in two
places, and the env one skipped the registry's status checks.
- **A `Router` interface on the envelope.** It existed only for this redirect
and hid routing inside an encryption concern.

## Consequences

Grievance depends on the chatbot's binding being active in the registry. Moving
the OTP service is one registry write, with no restart.
Loading
Loading