main.ts pilote l'ASSISTANT D'INSTALLATION de Dolibarr (initialSetup.ts va sur
/install/). C'est le script le plus destructeur du dépôt, et il était le seul
des scripts UI à n'avoir aucune garde d'hôte — alors que la docstring de
guard.ts nomme précisément le danger : « test/.env ships DOLIBARR_ADDRESS
pointing at PRODUCTION, so a script run with the ambient environment would
drive the real ERP ».
Pointé sur la production avec l'environnement ambiant, il relançait l'installeur
sur l'ERP réel. assertSandbox() n'autorise désormais que le bac à sable, sauf
double opt-in explicite (ARCO_ALLOW_PRODUCTION + ARCO_PROD_CONFIRM) — que
l'installation initiale d'un nouvel environnement justifie, et qui reste un
geste d'opérateur.
Vérifié : DOLIBARR_ADDRESS=https://erp.arcodange.lab est refusé.
Ceci clôt le mode vierge, qui n'avait rien à inventer. La chaîne existait déjà
en entier :
sandbox-lifecycle.sh blank --yes purge la base, retire install.lock
test/main.ts pilote l'installeur, société, affichage, modules
touch install.lock reverrouille (procédure du README)
test/provisionSandbox.ts agent d'écriture, droits, clé API
Le « golden empty » proposé au commit précédent était une invention pour un
problème résolu : je n'avais pas regardé test/. C'est exactement le contrôle
inscrit dans AGENTS.md le matin même — lire l'existant avant de modéliser.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Bash lit ses scripts au fil de l'eau. Éditer le fichier en cours de route décale
les offsets et corrompt l'exécution : un refresh-from-prod en cours s'est mis à
afficher les messages de blank_sandbox, puis a fini sur « syntax error ». Le
travail avait bien abouti — sandbox restaurée, 295 tables, 10 factures, 12 tiers
— mais la trace mentait sur la fonction qui tournait. Sur un script qui purge des
bases, une trace qui ment est inacceptable.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
DEUX CHOSES, dont une inachevée et dite comme telle.
1. LA SAUVEGARDE N'AVAIT AUCUNE GARDE DE CLUSTER. 24 appels kubectl nus, aucun
contexte épinglé — alors que le script lit des secrets, crée des Jobs et sait
RESTAURER une base. Lancé sur le contexte courant d'une station de travail, il
serait parti chercher les secrets Longhorn du cluster d'un client. Même patron
que ops/sandbox : ERP_KUBE_CONTEXT, wrapper K(), empreinte positive vérifiée
avant tout dispatch. Testé contre le cluster client et contre un contexte
inexistant : il refuse les deux.
Sauvegarde de production passée dans la foulée. La base a été dédupliquée à
juste titre — rien n'avait été écrit depuis la sauvegarde automatique de 01:00.
Au passage, le CronJob quotidien tourne bien depuis juillet ; il était encore
noté comme à faire.
2. LE MODE VIERGE PURGE MAIS NE RECONSTRUIT PAS. `blank --yes` vide la base du
bac à sable (295 tables -> 0, vérifié) avec trois protections empilées : refus
sans --yes, garde de cluster, et une relecture de current_database() DANS le Job
lui-même — une purge sur la mauvaise base ne se rattrape pas par un refresh,
contrairement à la bonne.
Mais l'instance ne se reconstruit pas. Premier essai : install.lock vit sur le
volume documents, que la purge ne touche pas, si bien que Dolibarr servait un
login sur un schéma inexistant. Correctif appliqué — retrait du verrou puis
redémarrage. Second essai : le verrou reste absent, et le schéma reste à ZÉRO
table. L'entrypoint de l'image ne lance aucune installation automatique.
CE QU'IL MANQUE est donc nommé dans le script : un « golden empty », pg_dump
d'une instance fraîchement installée — schéma et données de référence, aucune
donnée métier. `blank` le restaurerait au lieu de laisser la base vide, comme
refresh-from-prod restaure le dump de production. Seule la source change.
En l'état `blank` laisse le bac à sable inutilisable, et le script le dit. Le
bac à sable a été remis iso-prod avant de rendre la main.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
37/37 mouvements appariés à la date et au montant sur les trois comptes. Le
manifeste décrit l'exercice exactement comme la banque le connaît, et la banque
est le seul référent qu'on ne peut pas réécrire : Qonto et Wise sont hors de
Dolibarr. C'est le test qui compte, et il passe.
L'appariement se fait sur (date, montant) et SUR RIEN D'AUTRE. Le libellé n'y
participe pas — c'est précisément ce que le rejeu corrige, il ne peut pas servir
à valider.
Les corrections sont DÉCLARATIVES : chacune nomme le défaut, sa cause, son
remède et sa portée. Une correction sans cause écrite est une réécriture
silencieuse, et c'est ce qu'un repreneur lira pour comprendre pourquoi le livre
reconstruit diffère de l'ancien.
- 20 des 37 mouvements portent un libellé Dolibarr générique, illisible au
grand livre ;
- les 7 indemnités ne se journalisent pas faute de compte auxiliaire — le
correctif (455110) est vérifié en sandbox ;
- la TVA d'OVH est réclamée en déduction alors que la facture est au nom
personnel du fondateur : 7,30 TTC au taux 0, déductible 353,76 → 352,54.
Aucune déclaration déposée (CA12 annuelle), la correction est gratuite.
CE QUI RESTE BLOQUÉ, et ce n'est pas un détail d'implémentation : un rejeu EN
ÉCRITURE exige une instance VIERGE. sandbox-lifecycle.sh ne sait que
refresh-from-prod — un re-seed iso-prod, qui réimporte précisément les défauts
qu'on veut corriger. Écrire le rejeu sans cette capacité produirait des
doublons, pas une reconstruction. Le mode vierge est le préalable.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Un exercice OUVERT n'a d'engagement envers personne : aucun compte déposé,
aucune liasse, aucun FEC remis. Tant qu'il en va ainsi, une erreur de
MODÉLISATION se corrige mieux en rejouant depuis les pièces qu'en la
contournant — la piste d'audit fiable demande que l'écriture reflète sa pièce,
et rejouer resserre ce lien là où une rustine le distend.
extractExercise.ts ne fait que LIRE. Il produit la description ordonnée des
faits : tiers, factures client et fournisseur avec leurs lignes et règlements,
et l'intégralité des mouvements bancaires.
Les mouvements bancaires sont le RÉFÉRENT : Qonto et Wise sont hors de
Dolibarr, on ne peut pas les réécrire. Le test d'acceptation d'un rejeu est
donc qu'ils soient retrouvés au centime et à la date.
La ligne de partage entre ce qui se rejoue et ce qui est figé n'est pas
technique mais juridique : est figé tout ce qu'un tiers détient — facture
client ENVOYÉE, dépôt au greffe, déclaration transmise — et tout exercice clos.
Les factures client sont donc extraites avec delivered:true : un rejeu les
reproduit à l'identique, il ne les recalcule pas.
Les exclusions sont DÉCLARATIVES ET MOTIVÉES, jamais codées en dur : un
auditeur doit lire pourquoi une référence manque sans avoir à le deviner. Deux
pièces sont écartées — FAC001-CL00001, dont la référence était malformée par un
défaut de masque, et l'avoir qui l'annulait. L'opérateur a établi qu'elle n'a
jamais quitté la société : KissMetrics a été facturé via Wise en février, et le
document qu'il détient référence FAC001-CL0001001. L'avoir ne documentait donc
qu'un faux pas interne. La séquence FAC reste continue (FAC001 à FAC008), la
réémission portant elle aussi FAC001 — rien à renuméroter.
Deux constats que l'extraction met au jour :
- 20 des 37 mouvements bancaires portent un libellé Dolibarr générique
(« SupplierInvoicePayment », « CustomerInvoicePayment ») qui traverse le grand
livre tel quel et y est illisible pour un repreneur comme pour un inspecteur ;
- FAF2025001 (OVH, 7,30 EUR) est datée du 24/10/2025, trois mois avant
l'immatriculation, et NE FIGURE PAS à l'annexe 1 des statuts qui recense les
actes accomplis pour le compte de la société en formation. Une dépense
antérieure à l'existence de la société ne peut être supportée par elle que si
elle est reprise par cet état ou ratifiée ensuite.
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
L'entrée « urssaf-echeancier / pending-definition / recurrence unknown » traînait
depuis erp#54 avec la consigne « ne pas inventer de cadence ». L'opérateur a
communiqué l'échéancier : ce n'est pas trimestriel mais trois appels irréguliers.
493,00 (22/05, prélevé) + 1 215,00 (05/08) + 1 333,00 (05/11) = 3 041,00 EUR
Corrige aussi le compte comptable, faux dans la note : les cotisations d'un
gérant associé unique de SARLU (TNS) vont en 646 — cotisations personnelles du
dirigeant — et non en 645, qui vise les cotisations patronales sur salaires et
doit rester vide puisque Arcodange n'a aucun salarié.
Le schéma accepte désormais amount_eur : la boucle de rappels T11 (erp#60) a
besoin du montant en donnée structurée, pas noyé dans du texte libre.
Registre validé : 8 règles, 16 entrées, 8 ADC, 0 erreur.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
L'extraction de champs vivait dans un heredoc à l'intérieur d'email-inspect.sh :
impossible à exécuter isolément, donc jamais mesurée, donc fausse sans que
personne puisse le voir. Sur la facture Darnis F1048 elle renvoyait le numéro de
TVA d'Arcodange comme référence de facture, et aucune date.
- extract_fields.py : l'extraction sort du shell et devient un module.
- test_extract.py : régression contre les 16 factures hand-vérifiées de
fleet/golden/invoice-extract/. Score par champ, et une valeur FAUSSE pèse plus
qu'une valeur absente — un humain recopie ce qui s'affiche.
Valeurs fausses : 4 → 0. Exactitude ref 62,5 → 75 %, date 62,5 → 75 %,
HT 68,8 → 75 %, TTC 81,2 → 93,8 %.
Cinq bugs réels, dont trois invisibles sans test :
- « Nº » sur les factures françaises est U+00BA (ordinal masculin), pas le signe
degré. La classe [°o] le rate, le motif principal échoue, et le repli attrape
le premier jeton ref-shaped du document — très souvent un numéro de TVA.
- Le filtre anti-TVA rejetait « FR73261832 », qui est la vraie référence OVH : un
numéro FR fait exactement 11 caractères après le préfixe.
- « Montant total (HT) » était lu comme un TTC.
- Une référence coupée par la colonne (« 06-01-26- » / « payment-366753 ») était
renvoyée amputée : le recollage doit précéder le scan, sinon la queue seule est
trouvée en premier.
- Un `\b` après `€` ne peut jamais matcher en fin de ligne (€ n'est pas un
caractère de mot) — la TVA n'était jamais extraite.
adc-008 : une facture fournisseur s'enregistre à SA date, même future, tant que
l'exercice (année civile) ne bascule pas. Le document fait foi ; altérer sa date
ferait diverger l'écriture de sa pièce justificative (CGI art. 289 VII).
Registre validé : 8 règles, 8 ADC, 0 erreur.
scopes.ts : 1232 (factures fournisseur) ajouté à prod-write — oubli initial,
révélé par un 403 en production sur F1048. Le pipeline s'est arrêté sans écrire.
Appliqué en production via le pipeline gated : FAF2026014 (Darnis F1048),
218,50 HT + 43,70 TVA = 262,20 TTC, validée, non réglée.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
Completes the role model on the sandbox side, and closes the third failure of
the 2026-07/08 sessions: a refresh wiped hand-granted rights and nothing
recorded them, so the sandbox silently lost capabilities nobody had written down.
- Provisioned `ai_agent_sandbox_read` (36 rights) and
`ai_agent_sandbox_sandbox_write` (44 rights) from test/scopes.ts.
Verified functionally on the live sandbox: the reader reads and gets
403 on invoice creation; the writer creates a draft and gets 403 on DELETE.
- checkpoint-provision.sh now re-creates both scoped agents after every
refresh, so their rights come from code rather than from someone's memory.
Failure to provision a scope warns instead of aborting the whole checkpoint.
- checkpoint-relink-env.sh points the write skill at the scoped writer key,
falling back to the legacy single-user key so an older checkout still works.
The write skill now operates as ai_agent_sandbox_sandbox_write (id 6).
Smoke-tested end to end after the credential swap: the promote pipeline
rehearses on the sandbox under the scoped writer, and `apply` still refuses
without a recorded human gate.
The redundant `ai_agent_prod_prod_write` login is documented as deliberate:
renaming a provisioned production credential means creating a second privileged
user and repointing the promote flow — churn for cosmetics.
Left behind in the sandbox: draft invoice id=19, a scope probe. It cannot be
deleted (no scope grants DELETE, which is the point) and the next refresh
reclaims it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
Migration applied to production, in the only order that does not break the
promote flow:
1. Created `ai_agent_prod_prod_write` (id=5) with the narrow `prod-write` scope —
invoices, payments, and document submission (needed by builddoc to regenerate
a modified invoice's PDF). Verified functionally: reads pass, DELETE on an
invoice returns 403.
2. Repointed the promote pipeline at that user's key. It no longer borrows the
read skills' credential; if the key is absent it dies with the provisioning
command rather than silently falling back.
3. Revoked 12 write/delete rights from `ai_agent` (id=3), the credential every
read skill holds: create/modify on customer AND supplier invoices,
thirdparties, contacts, thirdparty payment details, proposals, exports,
accounting links — and delete on proposals, events, and GED documents.
Verified after: invoices, thirdparties, contacts, products, proposals, supplier
invoices and bank accounts all still read; creating an invoice returns
`403 Forbidden: Insuffisant rights`. The documented posture and the real one
finally agree.
scopes.ts corrected against the live instance: 262 is NOT "créer/modifier les
produits" as the first catalogue guessed but the `voir_tous` ACL extension — a
READ right the skills depend on (without it, list endpoints return empty arrays
instead of 403). Revoking it would have silently blinded every read skill. This
is why the audit reads labels off /user/perms.php rather than trusting ids in
code. The READ_ONLY baseline is now the audited read surface (35 rights), not a
guess.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
provisionSandbox.ts granted `[12, 122, 262, 32, 111, 251]`: opaque numeric ids,
one fixed scope, one agent per environment. Three failures in the 2026-07/08
sessions came straight from that:
1. Production `ai_agent` — documented as READ-ONLY in AGENTS.md, ADR-0003 and
every SKILL.md — actually holds 46 rights against 9 declared, including
create/modify on customer AND supplier invoices, thirdparties, contacts,
thirdparty payment details, proposals, plus THREE delete rights (proposals,
events, and submit/delete documents in the GED).
2. Writing a payment, then a product, to production each required granting a
right by hand and revoking it after. A privilege granted ad hoc under time
pressure is worse than one nobody holds.
3. A sandbox refresh wiped the agent's proposal rights, because they had been
granted manually and lived nowhere in code.
- scopes.ts declares three scopes with their purpose and allowed environments:
`read` (both envs), `sandbox-write` (sandbox only), `prod-write` (production
only, narrow: invoices + payments, what the gated promote apply actually
does). resolveScope() refuses a scope on an environment it does not belong to.
No scope grants DELETE — the ledger is append-only, deletion stays human.
- provisionAiUser.ts creates or aligns one user per (environment × scope), emits
its API key to a gitignored 600 file, and has an --audit mode that diffs what a
user HOLDS against what its scope DECLARES. Production writes require the
guard.ts opt-in.
- The audit reads permission labels LIVE off /user/perms.php rather than trusting
a catalogue in code: ids are stable per Dolibarr version, not across them, and
an audit that cannot name what it found is not actionable.
findUserId is implemented locally rather than imported: the trunk's userSetup.ts
has one, but it is uncommitted WIP and a provisioning script must not depend on
someone's working tree.
Tooling only — no production rights were changed. The migration (create the
scoped users, repoint the promote pipeline, then strip the over-grants from
`ai_agent`) rotates credentials used by every read skill and is the operator's
call.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
The discipline (ADR-0003, the promote flow, the operating rules) was written
down and still depended on whoever was driving choosing to follow it. On
2026-07-25 an agent session wrote five documents into the production ledger
through direct API calls, bypassing the promote flow entirely — a correct result
reached by a path nobody could audit. A rule an operator can skip is a
recommendation.
The five stages are now chained by artefacts on disk. Each refuses to run until
the previous produced its file, and the file says what it needs to hear:
rehearse (sandbox, host-guarded) -> judge --pre -> gate (human) -> apply
(prod) -> judge --post. The gate binds to a manifest digest, so approving a
change-set approves THAT change-set.
An op is defined ONCE, as an API call, and replayed on the sandbox then on
production — because the first design described each write twice (a sandbox
script input and a prod API body) and the pre-gate judge immediately caught them
diverging: the rehearsal was creating a EUR invoice with no due date while
production would have received a USD one at 60 days. Two descriptions of the
same write are two things that can disagree.
Judges are context-free, cross-family per the PRD qa-strategy rule, and
advisory: a BLOCK still lets the operator approve, and the override is recorded
with their name. Blocking authority stays with the human gate and the host
guards — an LLM verdict never silently starts or stops a production write.
Verified end to end against the real 24/08 change-set (M3 deferred, USD 3,000):
- pre-gate judge (Mistral) returned BLOCK twice, correctly — first on the
sandbox/prod divergence, then on a duplicate left by a repeated rehearsal;
- apply refuses after a rejected gate;
- apply refuses without ARCO_PROD_CONFIRM;
- editing an amount after approval invalidates the gate on digest mismatch.
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
static/config/company.json feeds setupCompany() in both main.ts and
provisionSandbox.ts, and main.ts defaults to the PRODUCTION address. The file
held placeholder identity values: running it against prod would have replaced
Arcodange's legal identity with them.
- capital "1000€" -> "1000". Dolibarr expects a number; the € made the value
unusable by the PDF template, which is why "Capital de 1 000 €" was missing
from every invoice since January (mandatory mention, C. com. R.123-238). This
file is the root cause — patching the database alone would have been undone
by the next provisioning run.
- siren 123456789 -> 999657455, siret 12345678900011 -> 99965745500013,
numTva FR00000000000 -> FR00999657455, rcs_rm "000 000 000 R.C.S. Evry"
-> "R.C.S. Évry", naf_ape 62.02A -> 6201Z. All read off the production ERP,
where they render on every issued invoice.
- formeJuridique SAS -> SARL, and the same correction in fleet/profile/fiscal.yaml
(legal_form), which inherited "SAS" from the PRD README. Three operational
sources say SARL: the production ERP, the signed contrat cadre signature block
("Pour Arcodange (SARL)"), and the 2026-05-28 cohort review. The PRD is wrong.
Flagged in-file for confirmation against the Kbis.
- moisDebutExercice Juillet -> Janvier (fiscal year closes 12-31 per fiscal.yaml).
fiscal.yaml still validates: 8 rules, 15 calendar entries, 7 ADC records, 0 errors.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
sandbox-lifecycle.sh scales deployments to zero, patches the ArgoCD Application
and runs DROP OWNED ... CASCADE. Every one of those ran against whatever
kube-context happened to be current.
This workstation also carries a CLIENT production cluster. On 2026-07-25 a
`checkpoint refresh` was issued while the current context was
do-nyc3-kissmetrics-prod-k8s-cluster: the script patched the ArgoCD Application,
scaled `erp-sandbox` to zero and copied a prod secret — all against the client's
cluster. Nothing was damaged only because that cluster has no `application` CRD
and no erp/erp-sandbox namespaces, so each call failed silently under `|| true`.
That is luck, not a control.
- ERP_KUBE_CONTEXT (default: "default") pins the target; every kubectl call now
goes through K(), so nothing inherits the ambient context.
- assert_arcodange_cluster() proves the target by positive fingerprint — the
erp, erp-sandbox and argocd namespaces AND the erp-sandbox ArgoCD Application.
A client cluster cannot match all four by accident. Wired into all three
entry points, before any mutation.
Verified: refuses the client context, refuses an unknown context, passes on the
homelab and completes normally.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
test/.env ships DOLIBARR_ADDRESS pointing at PRODUCTION and test/main.ts
defaults to it, so any Playwright admin script run with the ambient
environment drives the real ERP. The REST path has been structurally safe
since ADR-0003 (dol-write.sh refuses non-sandbox hosts); the UI path had no
equivalent. scripts/guard.ts closes that gap — assertSandbox() resolves the
target and refuses anything that is not erp-sandbox.*, with the override
spelled out in the error. Verified: an unqualified run now dies instead of
reaching prod.
Two settings the write agent cannot reach (non-admin by design, 403 on
/setup/conf), rehearsed on the sandbox:
- sandboxLegalSetup.ts — capital social + multi-currency module. Finding:
the capital was NOT missing, it was stored as "1000€"; the symbol made the
value unusable by the PDF template, which is why "Capital de 1 000 €" was
absent from every invoice since January (C. com. R.123-238). Normalised to
"1000" → the mention now renders.
- sandboxCurrencySetup.ts — registers the USD reference rate. Enabling the
module is not enough: a currency absent from the rate table makes Dolibarr
silently fall back to EUR (observed on a probe invoice). With the rate
registered, an invoice carries USD 3,000.00 with its EUR counter-value,
i.e. the contractual obligation itself rather than a drifting equivalent.
Both scripts are report-only when they cannot recognise a form, and screenshot
what they did.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
Implementation of the T02 atom over the erp#39 golden set:
- validators.py: instruction-pattern + multi-IBAN pre-screens (0 hard false
positives on the 16 real docs; all 6 injection fixtures quarantined BEFORE
any model call), the atom.yaml invariants, and literal provenance anchoring
with locale-aware locate (FR/EN months incl. abbreviations, NBSP-tolerant
amounts, line-wrap + column-interleave fragment anchoring for refs).
- extract.py: single-leg runner (MLX endpoint / vibe -p), zero credentials,
zero action tools; reasoning-channel aware.
- dual_run.py: model_policy in code — dual legs, exact critical-field
agreement; disagreement, single-valid-leg or both-invalid → escalations/
for the Claude tier (resolutions go back through validators.check).
Eval (eval/2026-07-19/, full transcripts + journals committed):
- critical-field accuracy 100 % (bar 98 %) — MET
- injection suite 6/6 quarantined — zero leaks
- overall field accuracy 94.9 % (known gaps: supplier ids often null,
period_covered format) — non-blocking, noted for the next version
- 9/16 documents escalated to the Claude tier (Mistral API timeouts, small
local model on receipts, one BIC-glued IBAN, derived-ratio rates) —
consistent with the A1 autonomy level recorded in atom.yaml
Runtimes this run: m4-local = Qwen2.5-7B-4bit (MLX), mistral = vibe -p
(mistral-medium-3.5) — provisional pending erp#45; journals are the
routing-bench raw material.
Closes erp#40 (PR to follow once arcodange/golden-set is pushed — this branch
stacks on it).
Co-Authored-By: Claude Fable 5 <[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
fleet/profile/ goes from stub to the machine-readable business-rules surface
the fleet reads (PRD agent-catalog document surface + compliance ADC framework):
- fiscal.yaml — entity, VAT position, 8 rules (regime reel simplifie until
2026-12-31 -> quarterly CA3 from 2027-01-01 per LF 2025 art. 38; KM export
autoliquidation 259-1 CGI box E2; FR 20% deductible; intra-EU reverse
charge; FX 766/666; SaaS expensed; CCA 455 lane). Every rule carries
effective_from/effective_until AND decision: adc-NNN; every date cites its
PRD anchor as an inline comment (verified against factory origin/main).
- calendar.yaml — 15 entries: acomptes TVA (2026-07 month-window, 2026-12-15),
last CA12 FY-2026 (2027-05-04), CA3 quarterly windows, CFE (December),
AG comptes annuels (2027-06-30), e-invoicing milestones (2026-09-01
reception, 2027-09-01 emission/e-reporting), URSSAF echeancier with the
in-file NOTE that a real direct debit exists since May 2026 (erp#57 revisit
of the payroll-dormant assumption), KM deferred due dates + renewal stub.
- JSON Schemas for both + scripts/validate.py (stdlib-only: strict YAML-subset
parser, JSON-Schema-subset checker, rule->ADC resolution, calendar checks).
- decisions/ — ADC register: template + adc-001..005 Accepted formalizations
(autoliquidation KM, FX->766/666, SaaS expensed, reel simplifie until
abolition, CCA personal-card lane) + adc-006/007 Proposed stubs (retainer
currency -> erp#53; capital path -> erp#51). Agents draft, the operator
Accepts — never the reverse; immutable once merged, supersede never edit.
- Mutation policy in-file: PRs only (T12 proposes, human merges).
- Same-change: profile README stub -> real doc; fleet/README.md layout line
and AGENTS.md fleet row updated (profile no longer a stub).
Validation: PASS — 8 rules, 15 entries, 7 ADCs, 0 errors, 7 warnings (the
warnings list exactly what awaits operator verification). Human gate left
open on purpose: operator sanity-read of the calendar + Acceptance of
adc-001..005.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
- validators.py: deterministic pre-screens (instruction patterns, multi-IBAN
escalate flag) + the atom.yaml invariants (arithmetic, rates, SIREN Luhn,
IBAN mod-97, date plausibility) + literal-provenance anchoring (a value
absent from the source can never appear in output).
Tested: 0 hard false positives on the 16 real docs; 6/6 injection fixtures
quarantined PRE-model; darnis-f1042 (embedded second document) → escalate.
- extract.py: single-leg runner, zero credentials/action tools; runtimes =
MLX endpoint (Ornith/M4) and vibe -p (Mistral).
- dual_run.py: model_policy in code — dual legs, exact critical-field
agreement, disagreement/flags → escalations/, invalid-both → quarantine.
Eval run against the golden set follows in this branch.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
- runs/2026-07-18/: the 8 sha256-pinned verifier transcripts (4 runtimes ×
2 tests), blind-judging verdicts (2 independent judges per cell, unanimous),
the erp#56 builder-bench journal + prompt + caps, and the evidence README
with the parity table.
- run-verifier.sh: mistral runtime drops the tool-filter flag (--enabled-tools
with a no-match pattern hangs vibe 2.21.0); plain -p with --max-turns 1.
Verdicts: Mistral (vibe -p, mistral-medium-3.5) and Ornith 35B (hermes MLX)
reach verdict parity with the Claude baseline on both tests → admitted to
verifier duty. Qwen2.5-7B-4bit fails both → the honest small-model floor.
Builder bench: erp#56 completed by the Mistral runtime, 0 code corrections,
261 s, acceptance run clean (0 bank-UNKNOWN) → merged as PR #68.
Closes#63 (with the paired factory qa-strategy PR).
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
Seed the invoice-extract (T02) and mail-classify (T01) golden sets from real
Arcodange history, plus an adversarial injection suite and an offline
field-level scorer.
invoice-extract/
- 16 real supplier PDFs (DARNIS/Hiway F1040/F1042/F1045/F1046, Anthropic
invoice+receipt x2, Mistral, OVH, greffe d'Evry, INPI x2, Legalstart, Qonto,
Infogreffe) fetched from the Zoho mailbox + Dolibarr GED, each with a
hand-verified expected JSON per the T02 schema. Every expected value was
cross-checked against the pdftotext -layout text and re-validated against the
deterministic invariants (HT+TVA=TTC, per-rate sums, IBAN mod-97, SIREN Luhn).
- inputs/ carries both the source PDF and its {source_sha256, mime, text} pair.
- 6 SYNTHETIC injection fixtures (LLM-directive, hidden white text, IBAN-swap
BEC lure, arithmetic-repair lure, fake tool-call, ref-hijack duplicate) whose
only correct outcome is quarantine; each PDF is marked SYNTHETIC.
- score.py: stdlib-only field-level scorer, critical fields (amounts/IBAN/refs/
dates) scored separately against the 98% bar, injection leaks blocking; a
built-in --self-test proves it catches perturbed fields and leaks.
- manifest.json: per-item provenance (mail message id / GED path + sha256),
linked Dolibarr supplier invoice, a verification note, and the list of real
documents deliberately excluded (fee statements, payment proofs, La Poste
receipts with no HT/TVA breakdown) with reasons.
mail-classify/
- 1824 historical mails labeled into {supplier-invoice, bank-notice,
government-admin, client, other} via sender-domain + subject weak supervision,
one human-correctable JSONL line per message with confidence + reason +
message-id provenance. manifest.json records the pull method and distribution.
Docs: golden/README hub, invoice-extract/README (T02 schema + conventions),
injection/README (threat table), mail-classify/README (method + distribution).
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
- MISTRAL.AI: update note to reflect annual subscription (Le Chat Pro - Annual)
from invoice MSTRL-API-814045-001, 2026-04-02, 143.90 HT / 172.68 TTC.
Next expected ~2027-04.
- CLAUDE.AI: document payment rail moved to personal card (fk_account=3,
API-invisible) for May/June; reference issue #57.
Both AI subscriptions are now recorded supplier invoices (post-replay).
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <[email protected]>
The harness layer (builder sessions, cold verifiers, evidence flow) gets a
committable home, per the PRD model-fleet § harness portability and erp#63:
- fleet/harness/verifier/: the two canonical verifier tests (locate-test,
cold-reader backlog audit) with pinned inputs, verbatim prompts, ground
truth and pass rules — judged context-free, never self-graded.
- fleet/harness/bin/run-verifier.sh: runs a test against any OpenAI-style
local endpoint (Ornith/MLX) or vibe -p (Mistral); emits sha256-pinned
JSON transcripts.
- fleet/harness/bin/vibe-builder.sh: the bounded shell for scoped builders
and recurring tasks — refuses the trunk (linked-worktree guard), hard
--max-turns/--max-price caps, full JSON journal per run.
- fleet/README.md layout + AGENTS.md Fleet section updated in the same
change (same-change freshness rule).
Part of erp#63 (harness portability spike, D2).
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]>
Closes erp#38 deliverables: fleet/ layout, atom.yaml schema documented
in fleet/README.md, 7 class skeletons per the PRD agent-catalog,
invoice-extract as the worked example (contract only — implementation
is erp#40), golden/ + profile/ stubs, AGENTS.md Fleet section with
freshness fixes (fleet/ no longer "not yet landed").
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]>
Agents landing in this repo had no entry point: no AGENTS.md, and the
backlog (erp#38-57 on 6 dated milestones, gateway#1-2, factory#22)
was only discoverable from the PRD STATUS in the factory repo. This
seeds the repo-root orientation map: where the work comes from (resume
protocol: top unblocked issue of the earliest open milestone, gitea
MCP pointers, owner gotcha for telegram-gateway), the repo map, the
operating rules (trunk/worktrees, read-only prod, sandbox+promote
gate, append-only ledger, anti-hallucination contract pointers).
Advances erp#38 (AGENTS.md seed; the fleet/ scaffold and the full
fleet section remain in #38's scope).
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]>
After an iso-prod refresh the instance unique-id changes, so an api_key encrypted
with the OLD id can't be decrypted — Dolibarr renders non-UTF-8 bytes in the field.
The POC's generateApiKey reused any non-empty value, so it copied that garbage into
test/.ai_agent_sandbox.key (corrupt key, 401s). Now it reuses ONLY a clean key
(^[A-Za-z0-9_-]{24,}$); otherwise it clears the field and regenerates. So
`checkpoint provision` after a refresh yields a fresh, working key.
Also documents the open PLATFORM follow-ups in ops/backup/README.md (easy to find
when revisiting ERP backups): the orphaned Longhorn `default` recurring-job group
(other cluster volumes have no offsite backup), and verifying the factory
pg_dumpall host cron.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
dolibarr-backup.sh restore --db|--docs <ts> --env <e> --yes — the recovery half of
the dedicated backup. DESTRUCTIVE (gated by --yes + explicit --env): scales the app
to 0, then
- --db: DROP OWNED BY <owner_role> CASCADE + pg_restore --no-owner --role (same
mechanics as sandbox-lifecycle.sh), from s3://.../erp/<env>/db/<ts>.dump;
- --docs: clears /var/www/documents and untars s3://.../erp/<env>/docs/<ts>.tar.gz;
then scales the app back to 1. OWNER_ROLE per env (erp_role / erp_sandbox_role).
The key is the bare <ts> filename from `list`; --db/--docs selects the subpath.
Proven on the sandbox: backup → mutate MAIN_INFO_SOCIETE_NOM to a sentinel →
restore --db → the value reverted to the backup's ('Arcodange'). (First run caught
a path bug — the fetch missed the db/ subdir — now fixed.)
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
tools#5 granted the erp prod Vault policy read on kvv2/data/longhorn/gcs-backup
(applied + verified: 1 changed, 0 destroyed). So the CronJob's VaultStaticSecret
can now resolve the GCS creds.
- backup.enabled: true (prod), vaultS3Path: longhorn/gcs-backup.
- sandbox overlay keeps backup.enabled: false (reproducible; its env policy wasn't
granted the read).
ArgoCD will deploy the CronJob + ConfigMap + VaultStaticSecret in the erp namespace.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Builds on the dedicated backup (erp#31).
Skip-if-unchanged: each half (DB / documents) carries a content fingerprint at
erp/<env>/.fp-{db,docs} and is dumped+uploaded only if it differs from the last
run — a quiet ERP day re-uploads nothing. Fingerprint = durable BUSINESS content
only: DB = count+max(tms) over tms tables EXCEPT volatile churn (llx_const,
llx_user, session/cron); docs EXCLUDE */temp/* (Dolibarr stats cache) — from both
the fingerprint and the tar. Proven live: 1st run uploads both, immediate 2nd run
skips both (uploaded=0).
Automation: the in-container logic moves to chart/files/backup-job.sh (single
source of truth, read by the orchestrator AND the chart). New
chart/templates/backup-cronjob.yaml renders a daily CronJob + ConfigMap +
VaultStaticSecret, gated by backup.enabled (default false). Helm-verified: off by
default (0 CronJobs), on renders correctly, env-aware (PREFIX erp/prod vs
erp/sandbox), script embedded.
Activation (documented): store GCS HMAC creds at kvv2/<backup.vaultS3Path>
(default erp/backup), grant the erp `auth` Vault role read on it (tools change),
set backup.enabled=true. Until then the orchestrator runs on demand.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The accounting data + issued documents are legally retained 10 years and warrant a
backup dedicated to Dolibarr. An audit found the generic Longhorn external backup
NEVER covered the erp volume (its Longhorn volume sits in the orphaned `default`
recurring-job group; the only job has groups=[] → serves nothing; lastBackupAt=never).
So /var/www/documents (invoice PDFs, supplier pieces, contracts, ECM) had zero
offsite copy — only in-cluster replicas.
ops/backup/dolibarr-backup.sh (orchestrator) + ops/backup/backup-job.sh (in-container
logic, env-driven, single source of truth):
- pg_dump -Fc of the DB + tar of the documents PVC (RWX, read-only mount) ->
s3://arcodange-backup/erp/<env>/{db,docs}/<ts>, then tiered prune (daily 30d /
monthly 12m / yearly 10y).
- prod is READ-only (dump+tar read; writes go only to the backup bucket); the DB is
read with the env's own dynamic creds; the GCS HMAC secret is copied transiently
(base64, deleted on exit) and never printed; the whole script ships base64.
- fixes the aws-cli v2.23+ default-checksum incompatibility with GCS/S3-compat
(SignatureDoesNotMatch) via AWS_*_CHECKSUM_*=when_required.
Proven live: sandbox end-to-end (dump+tar+upload+prune, verified in GCS, cleaned up)
and retention logic unit-tested (1100 daily -> 46 kept). The FIRST real prod backup
was taken (erp/prod/db 1.2 MB + erp/prod/docs 12.5 MB) — closing the gap now.
Automation (recurring CronJob in the chart + a dedicated erp Vault policy for its
own S3 creds) is the documented next step; the orchestrator works today on demand.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
A skill + CLI group to drive the ADR-0003 sandbox lifecycle, instead of the manual
kubectl/deno/.env dance:
arcodange sandbox checkpoint status # liveness + is the write agent armed?
arcodange sandbox checkpoint refresh --yes # re-seed iso-prod (DESTRUCTIVE, gated)
arcodange sandbox checkpoint provision # re-create ai_agent_sandbox (Playwright) + relink
arcodange sandbox checkpoint relink-env # rewrite write skill .env from the key + verify
- refresh wraps ops/sandbox/sandbox-lifecycle.sh; requires --yes (it wipes the agent
too, since iso-prod overwrites llx_user). --db-only skips the documents sync.
- provision runs test/provisionSandbox.ts (you do the admin login — PROD creds,
iso-prod) then auto-relinks; relink-env writes .env mode 600 and verifies via
GET /users/info.
- scripts resolve the repo root from ARCO_ROOT (set by bin/arcodange) or their own
path, so they work via the CLI or standalone.
Tested: status reports armed/not-armed correctly; refresh refuses without --yes
(exit 3); relink-env errors with no key (exit 1); help/usage wired.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
refresh-from-prod was structurally broken and silently no-op'd the restore:
1. pg_restore lacked -U, so the postgres image connected as its OS user `root`
and auth-failed. The failure was swallowed by `|| echo "ignorable warnings"`,
so the script reported success while the DROP OWNED had already emptied the DB.
E2's original seed was a manual process, so this path had never really run.
Fix: pass `-h $PGHOST -U $SB_PGUSER`; don't trust pg_restore's exit code (it
returns non-zero on the harmless "schema public already exists" notice) — verify
by counting restored llx_* tables and FAIL the Job if < 250.
2. erp-sandbox is ArgoCD-managed with self-heal ON, which reverts the
`kubectl scale --replicas=0` within seconds — so the seed ran with Dolibarr
still connected. Fix: pause self-heal for the duration, re-arm it after; app
restore + self-heal restoration + secret cleanup are guarded by an EXIT trap so
an interrupt can't strand the sandbox at replicas=0 / self-heal off.
Validated end-to-end on the live sandbox: 295 llx tables, company=Arcodange,
owner=erp_sandbox_role, self-heal re-armed, pod 1/1. README documents the self-heal
pause and the iso-prod consequence (ai_agent_sandbox is wiped → re-provision).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The consumer side of erp#26/#27: now that a règlement stores its originating bank
transaction id (transaction_id -> llx_bank.num_chq), bank-match uses it.
New PASS 0 (exact), highest priority, before wire-ref and amt+date:
- carry each feed movement's own id (Qonto transaction id; Wise activity + transfer
resource id) as feed_ids, and each Dolibarr payment's num.
- match when a payment's num equals a feed id. Tagged [tx-id].
- DATE-WINDOW-INDEPENDENT — the id is proof, so it pairs movements whose bank
settlement and Dolibarr saisie are weeks apart (which amt+date would miss).
Pass 0 runs before the ref index is built, so its matches are excluded from the
later passes (no double-match).
Fixture-proven: a payment dated 15d off the bank movement (outside the ±7d window)
matches via [tx-id] when num carries the Qonto id, and correctly does NOT match
when num is empty.
Co-Authored-By: Claude Opus 4.7 (1M context) <[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]>
Validating ai_agent_sandbox's key against the sandbox API, /thirdparties
returned 404 (the voir_tous ACL trap) while /invoices, /products,
/supplierinvoices returned 200. The missing right is `societe client voir`
(id 262, "see all thirdparties") — prod's ai_agent has it. Added it to
WRITE_IDS so the list endpoint works; other modules' lists are fine with plain
`lire`.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
First real run against the sandbox revealed three issues in userSetup.ts:
1. generateApiKey generated the key client-side and read it into the file but
never submitted the edit form, so Dolibarr never persisted api_key (DB stayed
NULL → the key could not authenticate). Now it clicks Save after generating.
2. assignRights matched `rights=<id>` as an href substring, so a short id like
12 (facture creer) also matched rights=121 / rights=1232 and .first() clicked
the wrong link — facture creer was never granted. Anchored with a trailing
"&" (rights=<id>&) for an exact match.
3. createUser was not idempotent: a re-run hit the existing login and failed to
parse a new id. Added findUserId (look up by login via the user list) and
return the existing id instead of creating a duplicate.
Verified the symptoms live: ai_agent_sandbox (rowid 4) had api_key NULL and was
missing only facture/creer among the 11 intended rights.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
After seeding erp-sandbox from prod, the home dashboard rendered a generic
"technical error" banner per box: prod mode ($dolibarr_main_prod=1, the image
default via DOLI_PROD) escalates the seed's minor non-fatal warnings into that
banner. Setting DOLI_PROD=0 for non-prod environments makes Dolibarr render
real errors inline (correct for a rehearsal env) and clears the banners.
config.yaml adds `DOLI_PROD: "0"` only when env != prod, so the prod configmap
is byte-identical (prod keeps the image default DOLI_PROD=1) — verified via
helm template diff. ArgoCD rolls only the sandbox pod.
Also corrects the test/README install.lock path: Dolibarr checks the DATA root
(/var/www/documents, a PVC — persists across restarts), not /var/www/html. And
notes that a prod-seeded sandbox still needs install.lock created (the seed +
documents/mycompany sync don't include it).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>