Doctrine posée par l'opérateur le 13/08 : si une donnée fautive est déjà en
production et sans incidence insurmontable sur l'exercice, on ne force pas la
correction — on annote l'objet. Le registre reste append-only et l'écriture
n'est pas maquillée.
La règle est juste, son canal habituel est fermé ici : sur ce déploiement,
AUCUNE écriture sur une charge sociale n'aboutit — ni la date, ni le montant,
NI MÊME LA NOTE PUBLIQUE. Toutes passent par le même UPDATE et échouent sur
« multiple assignments to same column fk_user_modif » (erp#87).
Ce qui fonctionne est un autre chemin : attacher un document, le téléversement
n'étant pas un UPDATE sur l'objet. La charge n°1 (URSSAF 1re échéance, 493 EUR)
porte désormais deux pièces : l'appel de cotisations officiel, et une note de
correction expliquant que le 22/05 est la date du PRÉLÈVEMENT quand l'échéance
officielle est le 05/05 — 17 jours de retard, écart resté dans le même mois et
le même exercice.
annotateObject.ts écrit la note publique d'un objet et REFUSE explicitement
quand le bug la bloque. Ce garde-fou vient d'un faux positif qui a bien failli
passer : la page de retour de Dolibarr ré-affiche le texte soumis, si bien qu'un
contrôle « le texte est là » réussit sur un enregistrement qui n'a jamais eu
lieu. J'ai d'abord conclu que le chemin des notes échappait au bug — il ne lui
échappe pas. Relire l'objet, jamais la page de retour. Le script reste utile sur
les objets non affectés par erp#87.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Dolibarr n'expose AUCUNE API REST pour les charges sociales (/taxes,
/socialcontributions, /chargesociales répondent tous « API not found »). Le
module est actif, seule l'API manque : le pipeline de promotion, qui parle REST,
ne peut pas porter cette opération. D'où un script UI, gardé par guard.ts.
La sandbox a joué son rôle : quatre doublons y ont été créés pendant la
découverte, sans conséquence, et les quatre pièges du formulaire sont désormais
documentés au lieu d'être redécouverts.
- recordSocialCharge.ts : idempotent (cherche la charge avant de créer),
vérifie par LECTURE de la liste, type TNS par défaut, --dry-run.
- RUNBOOK_charges_sociales.md : écrit pour être suivi par un agent moins
performant ou un harness limité — la commande, les garanties, les quatre
pièges, et ce que le script ne garantit PAS (ni juge, ni artefact de gate).
Les quatre pièges, tous rencontrés :
1. La date visible est décorative : le backend ne lit que les champs CACHÉS
echday/echmonth/echyear. Remplir le champ texte crée l'enregistrement avec
une période aberrante (20/06/2000 observé) au lieu d'échouer.
2. Le bouton de soumission n'a pas d'attribut name — le cibler par value.
3. L'URL après soumission ne porte pas d'id : vérifier par l'URL fait conclure
à un échec sur une création réussie. C'est ce qui a produit les doublons.
4. Les milliers portent une espace insécable (« 1 215,00 ») : une comparaison
littérale casse au-delà de 999 € et l'idempotence saute en silence.
Correction comptable : 645x → 646 dans known-patterns.json. Les cotisations d'un
gérant TNS sont des cotisations personnelles du dirigeant (646), pas des
cotisations patronales sur salaires (645) — Arcodange n'a aucun salarié.
Appliqué en production : les trois échéances URSSAF 2026, toutes IMPAYÉES.
Reste à vérifier dans le dictionnaire Dolibarr que le type « indépendants »
porte bien le code comptable 646.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
Two guards, both from real incidents in the same session.
1. invoice-create.sh — chronology (CGI art. 289). Dolibarr assigns the number at
validation, in creation order, so issuing a document dated BEFORE the last one
already issued gives a higher number to an earlier date. The July plan walked
straight into it: the M3 deferred part is due 2026-10-23 and must be issued at
D-60 (24/08) to stay under the L.441-10 I ceiling, while the M4 fixed part is
dated 23/08 — issue them in the wrong order and the numbering breaks. The
guard reads the last issued document of the same kind and refuses an earlier
date, with ARCO_ALLOW_BACKDATE as a loud, documented override.
Verified: refuses a 01/07 invoice against FAC008 (23/07), accepts 23/08.
2. test/scripts/guard.ts — production opt-in. The sandbox-only guard had no way
to express a deliberate production run, so any prod work meant bypassing it
entirely (which is how guards die). Production now requires BOTH
ARCO_ALLOW_PRODUCTION=<exact host> and
ARCO_PROD_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD, and prints a banner. Nothing
reaches prod by inheriting an ambient variable.
Also fixes a misleading "(sandbox verified)" log that printed even on prod.
grantAgentRight.ts joins the repo (it was never committed) and gains --revoke,
so a temporarily elevated right can be handed back — used today to attach a
payment in production and revoked immediately after.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
document-attach.sh uploads a source piece (the supplier's own PDF) onto an
invoice's GED via POST /documents/upload — idempotent by (object, filename,
sha256): before any POST the object's GED is listed and a same-named entry is
downloaded back and sha256-compared. Identical → deduped no-op; different
content → ABORT (refuse-never-repair, overwriteifexists always 0, never
Dolibarr's overwrite flag). Read-back after upload: re-list + download +
sha256-verify. Module-relative download paths are derived from the listing's
fullname (supplier invoices carry an id-derived get_exdir prefix like
9/2/FAF2026013/…, so reconstruction would be wrong).
Promote integration: new `attach` op in promote-plan/promote-apply (OP_SCRIPT),
object_id resolvable via @ref and #supplierinvoice lookups; a relative `file`
resolves against the manifest's directory (replay packs carry pdfs/ beside the
manifest, gitignored — README documents the books@ re-fetch message ids).
promote-plan prints each file's sha256 (or a loud MISSING) at review time.
CLI: `arcodange sandbox attach`.
Proof: offline case 12 in tests/run-tests.sh (upload body, dedupe, conflict
abort, field refusal, manifest-relative resolution via stubbed /documents);
live: manifest-C-ged-attach.json applied twice on the sandbox — run 1 four
created, run 2 four deduped, one GED file per FAF2026010-013, stored sha256s
equal to the re-fetched sources; tests/replay-idempotency.sh extended with an
attach op (4 created → 4 deduped, ged_files count unchanged) and a live
same-name/different-bytes abort verified.
Closes erp#43
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
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
Part of erp#65 (phase 1). Ledger grammar "thirdparty complete" gets its
op: allowlisted non-ledger fields, per-field diff read-back. Contacts
are born idempotent (dedupe by email then name). Promote ops wired both
targets, offline stub tests, SKILL.md workflows, KM dossier manifest
(unsigned-contract truth fix + EIN-to-collect note).
Co-Authored-By: Claude Fable 5 <[email protected]>
The pack (manifests, prelude, runbook, verify-provenance.py PoC) lived
only in an ephemeral session scratchpad while erp#41/#42/#43/#44 now
reference it as fixtures and the prod replay is still pending. 36/36
provenance checks were green at rehearsal time; PDFs are re-fetchable
via arcodange-email-ingest (documented in the pack README).
Co-Authored-By: Claude Fable 5 <[email protected]>
Qonto transaction ids run ~67 chars (<org>-<n>-<n>-transaction-<uuid>) but
Dolibarr stores num_payment in varchar(50) (llx_paiement.num_paiement,
llx_paiementfourn.num_paiement) — POSTing a payment with the raw id fails
HTTP 400 "value too long for type character varying(50)". Parade proven live
on the sandbox (2026-07-11): store the UUID suffix (globally unique, ~37
chars). Wise ids (short numerics) are unaffected.
Writer side — payment-record.sh strips everything through "transaction-"
before POST, announces the normalization on stderr, REFUSES (never truncates)
ids still >50 chars after normalization, and emits the normalized num in the
output JSON.
Reader side — bank-match.sh PASS 0 (exact tx-id, erp#28) now compares BOTH
sides in raw AND canonical short form: Qonto feed ids are carried long+short,
payment nums are normalized on compare — so nums stored short (the varchar(50)
form) and historical long-form nums both keep matching. Wise ids untouched.
Proven offline (no credentials, no network, no sandbox/prod writes):
- arcodange-bank-reco/tests/run-tests.sh — new bank-match --fixtures offline
mode: long feed id ↔ short num, long ↔ long (back-compat), Wise numeric,
each Δ+19d outside the ±7d window so only PASS 0 can pair them (exit 0,
3×[tx-id]); plus the empty-num negative (exit 1, 0 matched).
- dolibarr-sandbox-write/tests/run-tests.sh — payment-record via a stubbed
dol-write.sh (DOL_WRITE hook): long→short in POST body + output JSON, Wise
untouched, >50-after-normalization refused BEFORE any POST, citing
varchar(50).
Both SKILL.md document the canonical short form + the varchar(50) constraint.
Co-Authored-By: Claude Fable 5 <[email protected]>
The checkpoint status/relink-env armed probe calls GET /users/info, which
requires Dolibarr right 251 (user->user->lire). WRITE_IDS didn't include it,
so a freshly provisioned agent answered 403 on the probe — reported NOT armed
— while its key actually authenticates (GET /thirdparties -> 200). Right 251
was granted live in SQL on the sandbox (fk_user=4) today; this persists it in
WRITE_IDS so every future provision grants it.
Also teach both probes to tell the failure modes apart instead of one opaque
message: 401 = key rejected (stale/instance-encrypted -> re-provision),
403 = key OK but right 251 missing (-> grant it / re-provision), 200 = armed.
Docs updated accordingly (checkpoint SKILL.md probe outcomes, sandbox-write
SKILL.md gotcha, test/README.md rights table synced to WRITE_IDS incl. 262/111).
Co-Authored-By: Claude Fable 5 <[email protected]>
Make the originating bank transaction id a first-class input on payment-record.sh
so every règlement is tied to the real bank movement at write time.
- `transaction_id` is the canonical field (the Qonto/Wise feed tx id); `num` stays
as a back-compat alias. It's stored on the payment's bank line (llx_bank.num_chq),
the reconciliation key.
- Recording WITHOUT a transaction_id prints a stderr warning (still posts, but won't
auto-reconcile) — nudges the agent to always carry it.
- Output normalises to {id, bank_transaction_id, transaction_id}.
- Promote: manifests' payment ops carry transaction_id; promote-plan shows it
(tx=… or tx=MISSING).
Proven live: customer + supplier record with transaction_id; the `num` alias maps
to the same field; the no-tx warning fires; promote plan/apply carry it through.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
A payment only returned its paiement id, which isn't what bank reconciliation
keys on. payment-record.sh now emits {id, bank_transaction_id, num}:
- bank_transaction_id = the Dolibarr bank line (llx_bank.fk_bank_line) the payment
created, resolved via GET /{invoices|supplierinvoices}/{id}/payments (correlated
by num, else the most recent line). Works for customer and supplier.
- num stores the originating bank tx id (Qonto/Wise) and lands on that bank line's
num_chq — so arcodange-bank-reco can match a règlement to a statement line by id
instead of fuzzy amount/date. Both ends captured at write time.
Proven live: customer {id:13,bank_transaction_id:35,num:QONTO-TX-1234},
supplier {id:16,bank_transaction_id:36,num:WISE-TX-5678}; llx_bank rows 35/36
carry the refs in num_chq. promote-apply still extracts .id unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The two V9 follow-ups, both proven live on the sandbox.
- creditnote-create.sh: `kind:"supplier"` makes an avoir fournisseur on
/supplierinvoices (type=2 + fk_facture_source, carries ref_supplier); default
customer path unchanged. Proven: customer AVC002 (-240) + supplier AVF2026001
(-144, ref_supplier carried, linked to source, validated).
- bank-accounts.sh + `arcodange sandbox accounts`: list bank accounts (id/label/
bank) so a payment can pick its account_id. Needs `banque lire` (rights 111),
now added to the provisioner's WRITE_IDS so fresh runs include it; the existing
ai_agent_sandbox user was granted it live. GET /bankaccounts now returns the 3
accounts (QONTO, WISE EURO, Compte Courant Asso).
- SKILL.md: supplier-avoir example + accounts helper + updated banque-lire note.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Closes the last promote gap: a manifest can now reference records it does NOT
create. A value like "#thirdparty:name=KissMetrics" (or :code=CL0007) is looked
up on the TARGET at apply time and resolved to that target's id — so the same
manifest is portable (sandbox id on --target sandbox, prod id on --target prod).
promote-apply.sh: resolve() gains a "#" branch + a lookup() helper that queries
the target via the GET wrapper with sqlfilters. Supports thirdparty
(name/code/supplier_code) and invoice/supplierinvoice (ref/ref_supplier). A
lookup matching nothing OR more than one record ABORTS the run — it never
guesses, so it cannot write to the wrong entity.
Proven live: "#thirdparty:name=ACME Conseil" resolved to the existing client and
invoiced it; a not-found code and an ambiguous (2-match) name both aborted with
exit 1. Combined with @refs, arbitrary self-contained-or-referential change-sets
now replay cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The human-gated path that carries a reviewed sandbox change to prod.
- promote-plan.sh: render a manifest (JSON array of write ops with symbolic @refs
instead of ids — portable sandbox->prod) as a human-readable change-set.
- promote-apply.sh <manifest> --target sandbox|prod: replay it, resolving each
@ref to the id actually created during the run (dependent ops wire up). sandbox
rehearses via dol-write.sh; prod via dol-prod-write.sh.
- dol-prod-write.sh: the ONLY prod-write path. Prod key read from the ENVIRONMENT
only (DOLIBARR_PROD_WRITE_KEY, never a stored .env); every write refused unless
ARCO_PROMOTE_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD.
- create scripts take a DOL_WRITE override so promote-apply reuses them per target.
- bin/arcodange: `promote {plan|apply}` group + example manifest.
- payment-record.sh: fixed supplier payments (payment_mode_id + closepaidinvoices).
Proven live: plan renders; apply --target sandbox replays a 3-op chain with refs
resolved (@tp1->id, invoice socid=@tp1, payment invoice=@inv1); --target prod
without the confirm flag is REFUSED before sending. Supplier payment now works
end-to-end via the script.
Limitation (documented): manifests reference entities they create (@ref);
pre-existing prod entities need business-key resolution (follow-up).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- dolibarr-sandbox-write/scripts/creditnote-create.sh: create a customer avoir
(credit note) — a customer invoice type=2 referencing source_invoice
(fk_facture_source); amounts negative, validates to an AVC… ref. Proven live.
- bin/arcodange: new `sandbox` command group wiring the write scripts —
`arcodange sandbox {thirdparty|invoice|payment|creditnote|write}` (JSON on
stdin). Header + usage updated to note the CLI now does host-guarded sandbox
writes (still read-only on prod).
- SKILL.md: avoir workflow + CLI notes.
Verified end-to-end through the CLI: thirdparty -> invoice (FAC…) -> avoir
(AVC…, total_ttc -240, fk_facture_source set); host-guard intact via the CLI.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The write-capable companion to the read-only dolibarr* skills, scoped to the
erp-sandbox. Lets an AI agent rehearse bookkeeping writes against a copy of prod
(ADR-0003) before a human promotes the reviewed change to prod.
- scripts/dol-write.sh: write wrapper that REFUSES any host that is not
erp-sandbox.arcodange.lab (the structural prod-safety guarantee) using the
ai_agent_sandbox key from a gitignored .env.
- scripts/thirdparty-create.sh: create client/supplier fiches; codes auto-assign
via the elephant mask (code="-1").
- scripts/invoice-create.sh: customer (/invoices) or supplier (/supplierinvoices)
invoices with product/service lines + ref_supplier, optional validate.
- scripts/payment-record.sh: record a règlement (VIR/CB/CHQ/LIQ); customer pays
full + marks paid, supplier needs an amount.
- SKILL.md (safety model + workflows + the human-gated promote flow), .env.example,
example input.
Proven end-to-end live against the sandbox: client -> invoice (service+product
lines, HT 1100 / TTC 1320) -> validate -> payment (paid); supplier -> supplier
invoice (ref_supplier carried) -> validate. Host guard verified to refuse a prod
URL before sending.
Avoirs (credit notes) and bin/arcodange CLI wiring are planned follow-ups.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>