feat(write-skill): idempotency keys — manifest replay is a no-op (erp#44)
Learning #4 of the 2026-07-11 rehearsal: manifest B failed mid-run and could not be re-applied — op 1 (the DARNIS invoice) had already run and a replay would have duplicated it. Every write op now dedupes BEFORE any POST: - thirdparty-create.sh: by exact name (promote '#thirdparty:name=' semantics); ambiguous (2+) aborts; an existing fiche missing the requested role aborts (refuse-never-repair). Emits {"id", "deduped"} instead of a bare id. - invoice-create.sh: supplier kind by (socid, ref_supplier) — same key with a different total aborts as a conflict; customer kind (or supplier without ref_supplier) by (socid, date, total_ttc ±0.02, line fingerprint) with descs HTML-unescaped. Credit notes are never candidates. A deduped DRAFT with validate:true is validated on replay, so an interrupted run converges. - payment-record.sh: by (invoice, amount, normalized transaction_id), composing with the erp#37 varchar(50) normalization on BOTH sides so historical long-form nums still match; same tx + different amount aborts; without a tx id there is no dedupe key (warned). Dedupe answers id:null (the payments list exposes no paiement rowid) + the existing bank line. - All three refuse to POST blind when the dedupe lookup fails with anything but the documented empty-list 404 (the voir_tous trap would otherwise mint dupes). - promote-apply.sh: marks each op created / deduped=true inline and totals them in the summary — an all-deduped second run is visible proof of a no-op. - promote-plan.sh: advertises each op's dedupe key (and flags tx=MISSING as 'a replay WILL double-pay'). Proof: - tests/run-tests.sh: 5 new offline cases (11 total) — dedupe hits POST nothing, conflicts/ambiguity abort pre-POST, long-form history dedupes, draft convergence validates; stub extended to serve the new lookups with the live-observed empty behaviors ([] for invoices/payments, 404 for tiers). - tests/replay-idempotency.sh (new, live): double-applies a self-contained manifest on the sandbox — run 1 '3 created' (rows 1/1/1), run 2 '3 deduped' with row counts unchanged and the stored num in erp#37 short form. - The historic manifest-B now replays on the sandbox as 5/5 deduped, zero new rows — the exact replay Learning #4 declared impossible. SKILL.md updated in the same change (per-op dedupe keys, replay-safety section, gotchas); the 2026-07-11 runbook's Learning #4 carries a dated resolution addendum. Closes erp#44. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
This commit is contained in:
@@ -3,21 +3,58 @@
|
||||
# the DOL_WRITE env hook). Never talks to any host — sandbox and prod are both out
|
||||
# of reach by construction. Dispatch by endpoint:
|
||||
#
|
||||
# GET …/payments → serves $STUB_STATE/payments.json when present; else
|
||||
# (post-POST correlate) a list built from post_body.json;
|
||||
# else [] — the live sandbox answers [] (200) when empty
|
||||
# GET /thirdparties?… → name-lookup: serves thirdparties.json, else the
|
||||
# Dolibarr empty behavior (HTTP 404 + non-zero)
|
||||
# GET /thirdparties/<id> → serves $STUB_STATE/thirdparty.json (or a canned
|
||||
# "before" fiche on first read)
|
||||
# PUT /thirdparties/<id> → records put_body.json/put_endpoint, merges the body
|
||||
# into thirdparty.json (so the read-after sees it)
|
||||
# POST /thirdparties → records tp_post_body.json, echoes 90
|
||||
# GET /supplierinvoices?… → dedupe listing: serves supplierinvoices.json else []
|
||||
# GET /invoices?… → dedupe listing: serves invoices.json else []
|
||||
# GET /supplierinvoices/<id> · /invoices/<id>
|
||||
# → serves invoice_detail_<id>.json, else HTTP 404
|
||||
# GET /contacts… → serves $STUB_STATE/contacts.json; mimics Dolibarr's
|
||||
# empty-list behavior (HTTP 404 + non-zero) when absent
|
||||
# POST /contacts… → records contact_post_body.json, appends the contact
|
||||
# (id 88) to contacts.json, echoes 88
|
||||
# POST <anything else> → payment behavior: records post_body.json/post_endpoint,
|
||||
# echoes 77 (unchanged from the erp#37 tests)
|
||||
# POST …/validate → records validated_endpoint (proof validation ran)
|
||||
# POST <anything else> → payment/invoice behavior: records post_body.json/
|
||||
# post_endpoint, echoes 77 (unchanged from erp#37)
|
||||
# GET <anything else> → payments list served back from post_body.json
|
||||
set -euo pipefail
|
||||
STATE="${STUB_STATE:?stub-dol-write.sh: STUB_STATE not set}"
|
||||
METHOD="$1"; ENDPOINT="$2"; BODY="${3:-}"
|
||||
case "${METHOD} ${ENDPOINT}" in
|
||||
GET\ *"/payments"*)
|
||||
if [[ -f "${STATE}/payments.json" ]]; then
|
||||
cat "${STATE}/payments.json"
|
||||
elif [[ -f "${STATE}/post_body.json" ]]; then
|
||||
python3 - "${STATE}/post_body.json" <<'PY'
|
||||
import json, sys
|
||||
body = json.load(open(sys.argv[1]))
|
||||
print(json.dumps([{"num": body.get("num_payment", ""),
|
||||
"amount": body.get("amount", ""),
|
||||
"date": "2026-06-20 12:00:00",
|
||||
"fk_bank_line": "556"}]))
|
||||
PY
|
||||
else
|
||||
printf '[]' # live sandbox: empty payment list is [] with HTTP 200
|
||||
fi
|
||||
;;
|
||||
"GET /thirdparties?"*)
|
||||
if [[ -f "${STATE}/thirdparties.json" ]]; then
|
||||
cat "${STATE}/thirdparties.json"
|
||||
else
|
||||
# Dolibarr answers 404 (not []) when a thirdparty lookup matches nothing.
|
||||
printf '%s' '{"error":{"code":404,"message":"Not Found: No third parties found"}}'
|
||||
echo "stub-dol-write.sh: HTTP 404 on GET ${ENDPOINT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
"GET /thirdparties/"*)
|
||||
if [[ -f "${STATE}/thirdparty.json" ]]; then
|
||||
cat "${STATE}/thirdparty.json"
|
||||
@@ -40,6 +77,26 @@ json.dump(cur, open(p, "w"), ensure_ascii=False)
|
||||
print(json.dumps(cur, ensure_ascii=False))
|
||||
PY
|
||||
;;
|
||||
"POST /thirdparties")
|
||||
printf '%s' "${BODY}" > "${STATE}/tp_post_body.json"
|
||||
echo "90"
|
||||
;;
|
||||
"GET /supplierinvoices?"*)
|
||||
if [[ -f "${STATE}/supplierinvoices.json" ]]; then cat "${STATE}/supplierinvoices.json"; else printf '[]'; fi
|
||||
;;
|
||||
"GET /invoices?"*)
|
||||
if [[ -f "${STATE}/invoices.json" ]]; then cat "${STATE}/invoices.json"; else printf '[]'; fi
|
||||
;;
|
||||
"GET /supplierinvoices/"*|"GET /invoices/"*)
|
||||
ID="${ENDPOINT##*/}"
|
||||
if [[ -f "${STATE}/invoice_detail_${ID}.json" ]]; then
|
||||
cat "${STATE}/invoice_detail_${ID}.json"
|
||||
else
|
||||
printf '%s' '{"error":{"code":404,"message":"Not Found"}}'
|
||||
echo "stub-dol-write.sh: HTTP 404 on GET ${ENDPOINT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
"GET /contacts"*)
|
||||
if [[ -f "${STATE}/contacts.json" ]]; then
|
||||
cat "${STATE}/contacts.json"
|
||||
@@ -63,6 +120,10 @@ json.dump(rows, open(p, "w"), ensure_ascii=False)
|
||||
PY
|
||||
echo "88"
|
||||
;;
|
||||
POST\ *"/validate")
|
||||
printf '%s\n' "${ENDPOINT}" > "${STATE}/validated_endpoint"
|
||||
echo '{"success":1}'
|
||||
;;
|
||||
POST\ *)
|
||||
printf '%s' "${BODY}" > "${STATE}/post_body.json"
|
||||
printf '%s\n' "${ENDPOINT}" > "${STATE}/post_endpoint"
|
||||
|
||||
Reference in New Issue
Block a user