Configure Acumatica ERP purely from source — no UI, no Configuration Wizard. Idempotent ops: apply (keyed upserts), diff (drift detect, exit 2 on drift); tenant create bootstraps at birth. Done = clone data repo, decrypt, acu tenant create → acu apply → acu diff → byte-identical configured tenant; optional acu state commits derived-state observations under state/; warm acu state --assert-unchanged proves scenario idempotence.
- Repo =
acuCLI only. Data (baseline/,.env.gpg) lives in separate data repos (siblingacumatica-devops); infra = siblingacumatica-infra; blog = siblingacumatica-blog. C# customization projects out of scope (exclusion: bootstrap package ships one C# CustomizationPlugin package — §T.11; endpoint item returns when §T.12 lands). - Python ≥ 3.12; click, httpx, pydantic, pydantic-settings, rich, pyyaml; uv build; module
acumatica_cli; entryacu. - Default suite fully offline: SSH = monkeypatched
subprocess.run, REST =httpx.MockTransport; no live instance needed;make checkgate offline-only. Opt-in live tier: pytest markere2e, deselected via addopts, runs only viamake e2e; e2e self-contained — single-org synthetic company scaffolded from packagedconfig inittemplates into tmp dirs, no repo-root data symlinks, no dataset tenants (SalesDemo|T100|U100stay CLI surface, never test fixtures); multi-org out of demo scope (paid engagement). - Every cmd talks to live instance unless offline path:
--dry-run,bootstrap --export,inventory(artifact-only; no REST/SSH/password). Final verification live vs acu-dev1 (acu-dev1.vm.internal; needs tailnet + GPG key):cd ~/github/acumatica-devops && make decrypt && make difformake e2ehere (self-contained per the e2e constraint;.env/.env.gpgreal files @ repo root). - Unconfigured tenant fails most entities (500
PXSetupNotEnteredExceptionor 403 feature-gated) until bootstrap (features + company/branch); built-in endpoints can't bootstrap;PUT CompaniesStructuredead; payment terms have no Default-endpoint entity; financial currency (CM202000) has none either — RESTCurrency= CM201000 currency list only. - Dual readers single writer (V35): vocab — REST = live data-plane transport; entity = contract identity (e.g. Contact); seed = CaC YAML under
config/SEED_DIRS; tenant-snapshot artifact = SM203520 Settings XML ZIP orac.exe export xmltable-XML folder (ingress only); table/DAC = artifact identity; inventory/ = offline IR product; map join = table↔entity (snapshot_map.yamltable→entity or identity match), never seed-name↔inventory-name; REST extract = seed reader; offline inventory = artifact reader; sole tenant mutator = RESTapply; CLI never creates/restores SM203520;ac.exehas no snapshot create — consume artifact only; derived-state =acu state(I.cmd), not inventory not tenant-snapshot; bare "snapshot" ! synonym forstate(V32 rename) config init= single full seed scaffold (LAB5/gitops-class underconfig/+ lifecyclescenario/+ observerconfig/views/); package templates derive from siblingacumatica-gitopsseed trees (prune extras as needed —demo/, Makefile, live.env, committedstate/never packaged). Non-goals: multi-org, multicurrency, full tax engine; not production cutover. Offline e2e still green on same scaffold (mock transport; live e2e opt-in).
- cmd:
acu [--tenant <tenant>] [--url <base_url>] [--ssh <user@host>] [--api-version <v>] [--username <u>] [--password <p>] [--version] [--completion [<shell>]] <subcommand>→ globals valid only before subcommand; config globals override per key, first set wins: flag,ACU_*env (.envor process), code default — exclusionapi_version:--api-versionflag ? → else data-repotarget.yamldefault_apiwhen present → else code default25.200.001(noACU_API_VERSIONenv pin); creds: flag, env,admindefault (username only);--version→ editable install (PEP 610direct_url.jsondir_info.editable) renders<version>+dev (<checkout path>), release install plain<version>;--completion→ emit click completion script (bash|zsh|fish; ? →$SHELLbasename, unresolvable → error naming supported shells), eager exit like--version— enable = user sources output; value completion local-only per V23 - cmd:
acu tenant list|create|delete|recycle→ tenant CRUD + site recycle over SSH (ACU_SSHnon-empty post-default ! else hard error naming key before any remote); list → table titleTenants on <hostname>(host frombase_urlvia urlparse, never scheme/path; V9); create:--id! +--login! +--type/--parent/--hidden/--no-init?, then chains bootstrap publish (data plane, content-digest skip per V4) — tenant create + bootstrap one step on SSH boxes; --type validates client-side: choiceSalesDemo|T100|U100(V12-verified dataset folders on box;Systemexcluded — system-tenant dataset), omitted → clean tenant, non-member → error naming allowed set before any SSH, help text lists exact names; create resumable: login already exists → skip ac.exe create (--id! match existing CompanyID, else error), init + publish chain still runs — SSH-box republish route; hosted / no-SSH republish =acu bootstrap; delete:--id+ confirm prompt,--yesskips; recycle →Restart-WebAppPoolfor instance (kills all sessions → free concurrent API-user license slots; also reloads tenant map per V5); site-wide (no--id); confirm prompt,--yesskips - cmd:
acu bootstrap [--export <path>]→ publish AcuBootstrap into session tenant (data plane,/CustomizationApi); tenant ! (--tenant/ACU_TENANT); features + contract via dual bootstrap resolve (preferconfig/bootstrap/{features.yaml,project.xml}then rootbootstrap/…; absent → built-in six / packaged full companyBootstrap/1.0.0); content-digest skip per V4;ACU_SSHnon-empty post-default → post-publish app-pool recycle + wait (feature-slot cache), empty → warn skip recycle (hosted path, site restart may still be required);--export <path>→ offline zip write only (same feature splice + contract resolve, no HTTP, no SSH, no password) — SM204505 UI-import fallback; SSH never required - cmd:
acu apply [--dry-run] [files|dirs]→ PUT each record; dir arg: leaf expands*.yaml(skipfeatures.yaml); dir w/ any SEED_DIRS child (bootstrap|baseline|setup|master) expands nested those subdirs fixed SEED_DIRS order (umbrella e.g.config/); args ? →default_seed_dirs: data-repoconfig/has any SEED_DIRS child → onlyconfig/<name>/for present names, else root<name>/(legacy root); dual layout → preferconfig/only, never merge both; none exist → error exit 1 naming dirs; dry-run lineswould PUT …, summary suffixed(dry run) - cmd:
acu diff [files|dirs]→ GET by$filteron key fields, compare normalized; args ? + dir expand = same rules asapply(umbrella + preferconfig/); optimization-500 (delegate-view fields) → retry record via key-URL GET/<entity>/<key1>[/<key2>...](YAMLkeyorder); per-file progress on stdout matching apply banner (path -> tenant on url (entity)) + summaryN record(s) okorN record(s), M drift(s); drift dump + exit 2 end-of-run only - cmd:
acu run [--dry-run] [files|dirs]→ execute transaction scenario YAML; dir arg expands*.yaml; steps sequential:put(recursive wrap, detail lists native) |action(invoke + 202 poll) |wait(poll record until field match, timeout → fail) |get({entity, keys|filter, expand ?} — key-URL or list filter;captureresolves dotted/indexed paths into expanded details, e.g.Shipments[0].ShipmentNbr);capture:rides put + get steps, lifts server-assigned keys →${var}interpolation in later steps;once: true!presentinquire-absolute gate (author params +match? +whenfieldeq|gte) before steps — probe true → stdoutskip <path> (once: already present), no step HTTP, no expect, exit 0; probe false → steps + expects; no filename convention;expect:get(key-URL field assertions) +inquire(contract inquiry PUT$expand=Results) delta assertions — pre-run snapshot, post-run re-probe, delta compare (warm-tenant re-runnable additive); dry-run parses + lists steps + once annotate, no HTTP; exit 0 expectations hold or once-skip, 1 step error or expectation miss (2 stays drift) - cmd:
acu extract [--out <dir>] [--only <name>]... [--force] [--dry-run]→ inverse of apply: GET live tenant state → seed YAML underconfig/{bootstrap,baseline,setup,master}/(hard-cut; no root SEED_DIRS emit; no--layout); catalog-driven — package dataseed_catalog.yamlper row: entity, endpoint (symbolicbootstrap→ active package version @ load per V20, ordefault|literal), keys, file (path under data root, numbered prefix = V22 order; multi-file same entity = filter-split: one row per numbered file + filter? partition), strip deny-list or include allow-list (mutually exclusive), filter ? (OData$filterfragment → both list reads: plain GET + B9$selectfallback), features gates, kind ? (setup synth); features emitconfig/bootstrap/features.yaml;--out? cwd, created if absent; per-file skip-if-exists →skip <path> (exists),--forceoverwrites; zero live records →skip <path> (no records), no file;--onlyfilters entity name or file stem; per-row progress on stdout matching apply banner (path -> tenant on url (entity)) before outcome line (write/skip/would write); dry-run lineswould write <path> (<n> records); per-row failure isolation per V24 — row failure →x <name>: <reason>+ continue,PXSetupNotEnteredException500 →skip <path> (screen setup not entered), run ends w/ summary; emitted file ! parse viaload_baseline(V20 by construction) + byte-stable re-extract (records sorted by key tuple, key fields first then alpha, None/empty-string elided); exit 0 all rows wrote or skipped clean, 1 any row failed, never 2 — diff owns drift; ! derived-state write path (V32); REST-only — no snapshot/ZIP backend (V35) - cmd:
acu inventory [--out DIR] [--force] [--dry-run] <artifact>→ offline SnapshotArtifact → InventoryTree underinventory/(default--out); artifact = SM203520 XML ZIP (manifest.xml+*.xml) orac.exe export xmltable-XML folder — both normalize one IR (V37); binary.adbfail-closed; no REST no SSH no password; emitsummary.yaml+tables/<Table>.yaml(columns + rows, deterministic sort); skip-if-exists unless--force; dry-run would-write only; exit 0 clean, 1 parse/format/version fail, never 2; ! SEED_DIRS ! seed shape !endpoint:! apply path; !state(V32) (V35) - cmd:
acu reconcile [--inventory DIR] [--config DIR] [--out DIR] [--force] [--dry-run]→ FindingsBundle underfindings/(default--out findings/) from inventory tree + optional existingconfig/seed tree (prior extract); v1 never writesconfig/(V35/V36); findings cover unmapped/configured-not-CaC tables, REST gaps when config/ absent or partial vs inventory, entity-vs-table field/key deltas (prose synonym rest-vs-snapshot) when both sides comparable, custom columns; join keys = entity↔table viasnapshot_mapor identity, never seed filename↔inventory filename; compare path ! pad-trim both sides string keys+fields; seed→inv key/field aliases + FK CD↔ID resolve + enum label→code via inventory indexes / enum tables persnapshot_map/ package defaults (V38); v1{table,entity}map still loads; exit 0 clean, 1 IO/parse fail, never 2 (conflicts = findings files not drift); optional live re-extract not required v1 — consume disk trees; no SM203520 restore - cmd:
acu state [--out DIR] [--diff] [--assert-unchanged] [--dry-run] [FILES...]→ capture live derived state (balances, quantities, totals) → committed observation files understate/; args ? →config/views/hard-cut (noconfig/snapshot/fallback); dir expands*.yamlalpha; explicit paths win; default write--out(defaultstate/);--diffcompare live vs disk write nothing exit 0 either way;--assert-unchangedas--diff+ exit 2 if moved;--dry-runresolve views + validate sources no HTTP; exit 0 ok (change fine bare), 1 op fail, 2 only under--assert-unchanged; ! seed inverse — never writes SEED_DIRS/seed shape, neverendpoint:symbols, never apply path; views live underconfig/views/but ! SEED_DIRS; ! SM203520 tenant snapshot; ! inventory/findings paths (V35); !extract --only …;runexpect:deltastays aggregate field totals — row engine new (share client/auth/unwrap only); auth/session/target gate same as other data-plane cmds - cmd:
acu schema [--out <dir>]→ OpenAPI dump →schemas/(gitignored ~3 MB; regenerate, never version) - cmd:
acu config show→ emit resolved.env-format doc: header comment +ACU_*keys w/ resolved values (defaults merged incl derivedACU_SSH, flag overrides reflected; never emitACU_API_VERSION— api pin not env);ACU_PASSWORDnever emitted any form → output round-trips (acu config show > .envreloads identical, password excluded); when data-repotarget.yamlpresent → surfaceerp/default_apias comments + noteapi_versionsourced fromdefault_apiwhen flag absent (still exit 0); ! sameInstanceresolution path as live cmds — no parallel resolution; no target hard gate - cmd:
acu config init [--host <host>] [<dir>]→ scaffold data repo:.envACU_*placeholders omitACU_SSH+ACU_API_VERSION(--hostsubstitutes host intoACU_BASE_URLonly, placeholdererp.example.com; key absent → defaultAdministrator@+base_url host; hosted opt-out = present blankACU_SSH=;ACU_USER=admin+ACU_PASSWORD=blank, never real secrets; header comment →acu config show; env comments: where/secrets only + ssh default note, never API pin), omitACU_API_VERSIONline; committedtarget.yaml(erp+default_api= sole Default contract pin when present; scaffold defaults match CLI-verified matrix); no--flavor— single full template underconfig/{bootstrap,baseline,setup,master}/+scenario/+config/views/10-trial-balance.yaml+ short README; package content ! derive from siblingacumatica-gitopsseed trees (prune non-seed extras:demo/, Makefile, live.env, committedstate/); bootstrap company + features + credit-terms +project.xmlfull companyBootstrap/1.0.0; baseline GL chain (+ packaging rows when present in source); setup fin-year/master-calendar/open-periods; master inventory/SO/PO/AR/AP/CA surface (extended features Inventory/DistributionModule/Warehouse/WarehouseLocation); scenario lifecycle alphabetical:10-seed-capital.yaml(once: true+ authoredpresent) +20-buy.yaml+30-build.yaml+40-sell.yaml(gitops names; monoscenariobuy-sellforbidden); state TB EndingBalance-class numeric money capture per V33 (inventory-summary ! golden); post-scaffold next-step cmds preferbootstrap,apply config/,run scenario/,diff config/,state;<dir>? cwd, created if absent; per-file skip-if-exists →skip <file> (exists), exit 0, never overwrites; templates = package data viaimportlib.resources; nogit init, no gpg - cmd:
acu config check [--strict]→ read-only preflight, dependency order, oneok/fail/warn/skipline each (stdout viaoutput.data): discovery (walk-up finds.env+ parse +ACU_BASE_URL) → secrets (ACU_PASSWORDresolved) → target (local: present → load ok +Instance.api_versionfromdefault_apiunless--api-versionflag; missing under data root →warn,--strict→fail; invalid →fail; no data root →skipor omit) → REST (login + landed-tenant verify + logout) [→ endpoints Default presence via dual-shapeGET /entityparse per V31] ∥ ssh (non-empty post-default → trivial remote via_ssh; empty →skip ssh (ACU_SSH not set), never fail); REST + ssh probe independently when ssh non-empty; discovery/secrets fail stops; no PUTs, no tenant CRUD; exit 0 when nofail(warns allowed); exit 1 any fail; 2 stays drift - cfg:
Instance= pydantic-settingsBaseSettings, env prefixACU_, keys:ACU_BASE_URL(REST root: scheme + host + site path),ACU_SSH(control planeuser@host: flag/env when key present wins incl blank; key absent + base_url host → code defaultAdministrator@+hostname; key present blank → empty = hosted/data-plane only; tenant cmds hard-error when empty post-default naming key),ACU_TENANT,ACU_USER(defaultadmin),ACU_PASSWORD; fieldapi_version(version half only —/orDefault/prefix → hard error; name half hardcodedDefault— custom endpoints = per-call seedendpoint:overrides) ! resolved outside env:--api-versionflag ? → elsetarget.yamldefault_apiwhen present → else code default25.200.001; neverACU_API_VERSIONenv key (unknownACU_*ignored); other flags override env per key; install-layout values =config.pyconstants (AcumaticaERP— IIS pool coupling documented @recycle_app_poolcall site,C:\Acumatica\AcumaticaERP,ac.exepath,AcumaticaDB), not config surface - env:
.env? — sole config file: where + secrets asACU_*vars; discovery sentinel, walk-up per V3; encrypted at rest as.env.gpg; absent → resolution runs on flags + process environment alone; password ! resolve via--passwordorACU_PASSWORD; username ? via--usernameorACU_USER, defaultadmin; never holds dataset target matrix or Default API pin (V2/V27 — noACU_API_VERSION) - data:
baseline/*.yaml→entity/key(string or list) /records+endpoint? (literal e.g.Bootstrap/1.0.0|Default/25.200.001or symbolicbootstrap→ active package version @ load per V20, or symbolicdefault→Default/<Instance.api_version>@ HTTP viaclient._urlper V20); parsed byseed.py - data:
master/*.yaml→ same seed shape as baseline (entity/key/records/endpoint?); default apply/diff aftersetup/; init scaffolds underconfig/master/ - data:
setup/*.yaml→ action file:action/entity/record+parameters? +endpoint? (literal or symbolicbootstrap|defaultper V20) +done_when({entity?, endpoint?, filter?}; entity+endpoint default to action's); onerecordper file — multi-invocation = multiple numbered files; realizedPOST /entity/<endpoint>/<Entity>/<Action>body{entity, parameters}→ 204 done or 202 pollLocation; parsed byseed.pydispatch onaction:key —apply/diffabsorb kind transparently - data:
target.yaml→ ? committed verified matrix @ data-repo root:erp(claimed product line/build) +default_api(Default version half only, neverDefault/...path; sole data-repo source ofInstance.api_versionwhen--api-versionflag absent); present → load per V27 (sources api_version + erp claim) on allowlisted data-plane cmds +config check; absent → legacy warn onconfig checkunless--strict+ code-default api_version; invalid → hard fail any loader; never secrets (V2) - data:
bootstrap/features.yamlorconfig/bootstrap/features.yaml→ ? list ofFeaturesSetproperty names (DAC bit flags) → pluginEnabledset @ package build; resolve preferconfig/bootstrap/then rootbootstrap/; absent → built-in six (FinancialModule, FinancialStandard, DistributionModule, Inventory, Branch, MultiCompany) - data:
bootstrap/project.xmlorconfig/bootstrap/project.xml→ ? endpoint contract (entities, fields, name+version identity); resolve preferconfig/bootstrap/then root; present → package_zip uses it; absent → packaged full company contract (Bootstrap/1.0.0surface: company + credit terms + financial currency + GL chain + module prefs + vendor class + statement cycle + warehouse + order type + cash account + …); shape change ! version bump per V21; init scaffoldsconfig/bootstrap/project.xml - data:
config/{bootstrap,baseline,setup,master}/→ default init seed layout (apply/diff umbrella + extract hard-cut emit); V30 still honors legacy root SEED_DIRS for apply/diff read only; extract never emits root SEED_DIRS;config/views/co-located underconfig/but ! SEED_DIRS (! apply/diff/extract seed) - data:
seed_catalog.yaml→ packaged extract registry (rename fromextract_manifest.yaml); sole place naming extractable seed rows: entity, endpoint, keys, file, filter?, strip|include?, features?, kind?, phase?; apply/diff never load @ runtime — catalog = extract + CI completeness + docs; completeness per V34 - data:
scenario/*.yaml→ scenario file:scenario/description? /steps(eachid+ one op key ofput|action|wait|get+record? +capture? +wait?; empty steps ok — stub no-op) +expect? +once? (true!present:inquire+parametersauthor-owned incl Period +match? +whenper-fieldeq|gteabsolute gate; no filename convention; no CLI capital default; no${var}in present — probe pre-steps) ; transactions never keyed upserts —runowns them,apply/diffnever readscenario/; golden set =10-seed-capital+20-buy+30-build+40-sell(gitops names); parsed byrun.py - data:
config/views/*.yaml→ view def (observer config, ! seed):name(output stem understate/) +source(exactly one ofgi:|entity:|inquire:+params? pinned in YAML;inquire:= contract inquiry entity name) +key(sort + identity cols, unique across rows) +captureallowlist +decimals? default 2; stemtrial-balance! ≥1 numeric money field in capture (V33); inventory-summary ! golden this pass; alphabetical prefix order likescenario/; bare default hard-cut this tree (noconfig/snapshot/fallback);apply/diffnever readconfig/views/; parsed by state engine - data:
state/*.yaml→ observation file (committed derived-state evidence, ! seed):view+erp(live build provenance) +rowsflow-style one mapping per line (key-sorted; money = fixed-point strings @ viewdecimals); never under SEED_DIRS; neverendpoint:; never apply path; series history =git log -p state/— no in-tool baseline/chart; hard-cut default--outstate/ - data:
inventory/→ SnapshotArtifact emit (! SEED_DIRS, ! seed):summary.yaml(erp/build, export mode, table row counts) +tables/<Table>.yaml(columns + rows); neverendpoint:;apply/diff/extractnever load; engagement-generated —config initdoes not scaffold (V35/V37) - data:
findings/→ reconcile emit (! SEED_DIRS, ! seed): unmapped tables, REST gaps, entity-vs-table deltas (prose synonym rest-vs-snapshot), custom columns; never apply path; engagement-generated —config initdoes not scaffold (V35/V36) - data:
snapshot_map.yaml→ ? package table→entity map (catalog entity name; sibling spirit ofseed_catalog.yaml); join = table↔entity not seed filename↔inventory filename; beyond v1{table,entity}: optional key/field alias maps + FK resolver specs (table + id col + cd col) + global enums: label→code tables + per-row field→enum; v1 rows still load; natural keys + pad-trim + FK CD↔ID resolve + enum label→code + strip|include parity (V38); v1 optional cross-check only — never writesconfig/; promote path post-v1; ! V34 catalog completeness surface - api:
/entity/Default/<api_version>/(default25.200.001) → cookie-session httpx; values wrapped{"Field": {"value": ...}}; PUT = keyed upsert - ssh:
ac.exe -cm:CompanyConfig+sqlcmdoverssh→ remote shell PowerShell;exit $LASTEXITCODEappended so failures propagate - pkg: pypi.org
acumatica-cli→pip install acumatica-cli; publish = GitHub Actionsrelease.ymlon tagv*after reusable CI check passes; trusted publishing (OIDC),uv buildsdist+wheel + GH release create; no stored API token
V1: two-plane split — control plane = SSH (tenant.py, tenant CRUD only); data plane = REST (client.py); never mixed
V2: two source kinds never mixed — baseline/*.yaml = what, .env = where + secrets (ACU_* vars, never what); both live in data repos, not here; never hardcode company surface in plugin source; bootstrap source closure → .spec/check-extras.md §V.2
V3: discovery — walk up from cwd to first dir containing .env; found → loads; absent → resolution on globals + process environment; hard error only when required value unresolved post-merge, error names missing key(s); resolution matrix → .spec/check-extras.md §V.3
V4: idempotence — PUT keyed upsert is the primitive; diff treats source as authoritative, extra live records not flagged; drift → exit 2; resume/skip gate ! verify desired state, never a marker — includes scenario once skip-if-present via authored present inquire-absolute probe (not marker file; cold expects skipped on skip) (gh #19); $filter key literal types by YAML scalar — bool/number bare, string quoted (closes §B.23); full audit recipe → .spec/check-extras.md §V.4
V5: tenant-map — tenant create ! AcumaticaERP app-pool recycle after ac.exe; always send explicit valid tenant; data-plane session ! post-login landed-tenant verify, refuse on mismatch; apply that creates Company mid-session ! re-login same client before later PUTs resolving branch selectors (virgin-tenant TransitBranchID class; warm Company re-PUT re-login-safe) (closes §B.24); symptom recipe → .spec/check-extras.md §V.5
V6: AcumaticaClient ! context manager — logout even on failure (sessions count vs license API-user cap); logout ! Content-Length: 0 (else IIS 411)
V7: CompanyConfig ! -h beside -iname + -dbnew:"False"; delete uses Deleted sub-key + full spec (ParentID + CompanyType); create presets admin via -aun/-aup/-auc — contract API can't clear PasswordChangeOnNextLogin, Login.aspx screen flow = fallback only
V9: output — everything through output.py, no bare print(); stdout = data, stderr = process; ASCII-only every path; exit 0 ok, 1 error, 2 drift; once-skip line skip <path> (once: already present) on stdout data path (gh #19); no --json — plain text = machine interface; transport/network fail (connect/timeout/TLS) → one x line naming class + target + reachability hint (check ACU_BASE_URL / instance up), never raw httpx/OS dump; ACU_DEBUG=1 re-raises; tenant list table title Tenants on <hostname> — host from base_url (urlparse), never full URL (closes §B.27); full audit recipe → .spec/check-extras.md §V.9
V10: every model inherits models.Model (pydantic frozen, extra="forbid") — validate at boundary, unknown fields error; mechanical form: ^class .*BaseModel outside models.py = violation (.spec/scripts/check-extras.sh scan); class w/o pydantic base = not a model, exempt
V11: REST targets versioned path only (Default/25.200.001), never unversioned alias
V12: docs/ac-exe.md + docs/rest-api.md verified vs live 26.101.0225 — trust over training data, re-verify on upgrade; dumped schema (acu schema) = authoritative field reference
V13: make check (ruff + basedpyright strict + offline pytest) before every commit
V14: friction record — major Acumatica API friction (vendor docs wrong/missing, silent no-op write, read 500/empty w/o cause, feature gate) → gh issue on this repo: symptom, then live evidence, then workaround/fix; dead ends stay recorded (findings, not noise)
V15: cmd grammar — exactly two forms: acu [globals] <noun> <verb> [options] = resource ops; acu [globals] <verb> [options] [args] = data plane; no third form; surface encodes V1 split; verb map → .spec/check-extras.md §V.15
V16: option conventions — globals valid only before subcommand; resource identity = explicit --id, never positional; --dry-run wherever mutation; destructive ops confirm prompt, --yes skips; enum-domain option value = click.Choice @ parse over V12-verified set, free string never forwarded to remote accepting any value (closes §B.20); full convention audit → .spec/check-extras.md §V.16
V17: spec-state dependencies stay live — §T verify gate ! satisfiable vs current spec state; retirement ! re-route every recorded recovery/fallback role (closes §B.17); premise assertions ! probed @ authoring (closes §B.18); full audit recipe → .spec/check-extras.md §V.17
V18: _ssh appends exit $LASTEXITCODE to every remote command — single choke point, call sites never hand-append (PowerShell-over-ssh returns 0 on failed native cmd)
V19: release pipeline — make release <part> sole path (make check first, then bump + commit + tag + push; never local gh release create); tag v* → release.yml OIDC pypi + GH release; no PyPI API token; tag v<version> == pyproject version; CI on every push/PR to main; full audit recipe → .spec/check-extras.md §V.19
V20: seed endpoint routing ! unambiguous — dual-served entity ! explicit per-file endpoint: (literal or symbolic bootstrap|default); absent → hard error naming both symbols, never silent Default-endpoint PUT; resolution recipe → .spec/check-extras.md §V.20
V21: endpoint contract identity = name+version — entity or field shape change in active contract XML (config/bootstrap/project.xml or bootstrap/project.xml or packaged full company fallback) ! version bump; single contract Bootstrap/1.0.0; full audit recipe → .spec/check-extras.md §V.21
V22: baseline reference closure — record field referencing another entity ! that entity exist @ PUT time: tenant-native or created by earlier-sorting baseline file (dir expansion alphabetical = sole ordering mechanism within a seed dir; umbrella expands SEED_DIRS order first; filename prefixes encode order); feature closure sibling — file whose entity feature-gated ! its FeaturesSet name enabled in resolved features file (config/bootstrap/features.yaml or bootstrap/features.yaml); full audit recipe → .spec/check-extras.md §V.22
V23: completion path local-only — --completion script emit + dynamic value completion read package data + data-repo files (cwd walk-up) + .env only; never REST, never SSH, never live instance (fires per keystroke)
V24: extract per-row failure isolation — row failure → continue + report; empty-state class ! skip not fail; virgin tenant full-manifest exit 0 (closes §B.19); exit/msg matrix → .spec/check-extras.md §V.24
V25: seed-file key identity — declared key tuple ! uniquely identify records; extract dup key tuple → row failure per V24, file never emitted; load_baseline dup key tuple → hard error naming entity + first dup tuple — catches hand-authored files (§B.14 workaround class; closes §B.21)
V26: org-scoped view read ! explicit org context — GL201100-class entity answers 200 [] multi-org w/o org param; probe/synth ! org-parameterized or per-org iterate; single-org green ≠ multi-org proof (closes §B.22); audit recipe → .spec/check-extras.md §V.26
V27: dataset target — data-repo target.yaml ? declares verified erp + default_api (version half only); present → Instance.api_version ! = default_api @ load when --api-version flag absent (single source, dual-source match gate retired); never ACU_API_VERSION or default_api in .env (V2); flag ? overrides target for ad-hoc; absent target → code default 25.200.001; gate allowlist + placement → .spec/check-extras.md §V.27
V28: init-template — config init single full seed (no --flavor); layout config/{bootstrap,baseline,setup,master}/ + scenario/ lifecycle 10-seed-capital+20-buy+30-build+40-sell + config/views/10-trial-balance.yaml (TB-only golden, V33); package templates ! derive from sibling acumatica-gitops seed trees (prune non-seed extras); package templates/**/*.yaml ! data only (no # comments — full-line or trailing); narrative/docs comments ! sibling acumatica-gitops separate files, never inlined package YAML; monoscenario buy-sell forbidden; full company contract Bootstrap/1.0.0; root SEED_DIRS ! default scaffold (V30 dual-layout still honors legacy root data repos); full audit recipe → .spec/check-extras.md §V.28
V29: init-org-cd — init scaffold ! single org-CD placeholder token across company AcctCD, ledger-company, open-periods OrganizationID, INPreferences TransitBranchID, cash-account BranchID
V30: seed-layout dual — bare apply/diff defaults prefer data-repo config/ SEED_DIRS when any child present, else root SEED_DIRS; never merge dual trees; explicit path args still honored; bootstrap package inputs dual-resolve config/bootstrap/ then bootstrap/ (gh #19); extract emit hard-cut config/ SEED_DIRS only (no root emit, no --layout)
V31: entity-list dual-shape — parse_entity_list accepts top-level JSON array or object w/ endpoints array (same name/version string row rules); empty/unparseable → fail-closed (gh #20; T74 array-only broken on 26.x wrapper)
V32: derived-state-observation — derived state ! seed: views config/views/*.yaml, captures state/<name>.yaml; bare defaults hard-cut those paths; never SEED_DIRS/seed shape/endpoint:; apply/diff never load them; money = Decimal → fixed-point @ decimals (default 2); exit 2 only --assert-unchanged when moved; ! SM203520 tenant snapshot; ! inventory/ ! findings/ (those = dual-reader products V35, not balances); full audit recipe → .spec/check-extras.md §V.32
V33: observation-source — each view exactly one of gi:|entity:|inquire:; entity: → Default REST list GET; gi: → OData GI + $metadata fail-closed; inquire: → contract inquiry PUT $expand=Results; stem trial-balance ! ≥1 numeric money field; inventory-summary QtyOnHand golden ! this pass; full audit recipe → .spec/check-extras.md §V.33
V34: seed-catalog completeness — every packaged templates/config/** seed yaml (except project.xml, features synthesis, config/views/) ! exactly one seed_catalog.yaml row; multi-file same entity ! filter-split (one row per numbered file + optional filter/match partition); template file set = catalog file set = extract emit paths; no hand_only markers; golden seed ! claim fields GET never returns (B11 class; packaging UOMs drop until verified read-back)
V35: dual-reader single-writer + vocab — REST = live data-plane transport only; entity = contract/REST identity; seed = config/ SEED_DIRS YAML (CaC artifact, ! synonym for REST); tenant-snapshot artifact = SM203520 XML ZIP or ac.exe export xml folder (ingress only; ! side name like XMLDUMP; bare "snapshot" ! state); table/DAC = artifact identity; inventory/ = offline IR (tables/<Table>.yaml); reconcile map = table↔entity (snapshot_map or identity match) never seed-name↔inventory-name; readers = REST extract (seed under config/) + offline inventory (artifact → inventory/); sole tenant mutator = apply PUT (V4); never SM203520 restore / ac.exe import / binary import; inventory/ + findings/ never SEED_DIRS never apply/diff load; v1 artifact path writes inventory/ + findings/ only — never config/ from artifact; extract REST-only
V36: seed authority — v1 writers into config/ = REST extract + human only; when REST extract succeeds for entity, seed/entity always wins vs inventory/table; field/key conflict → findings/ never silent overwrite seed; post-v1 SnapshotMap promote only on REST gap (not prefer-artifact flag); unmapped tables never enter config/
V37: tenant-snapshot artifact — accept SM203520 XML ZIP (manifest.xml + *.xml) or ac.exe export xml table-XML folder; both normalize one IR → inventory/; binary .adb fail-closed named error; when data-repo target.yaml present inventory erp/build from manifest or header ! match erp else hard error (sibling V27); format tag "XML dump" ? for ingress only — never identity side name (table owns identity)
V38: reconcile-normalize — entity-vs-table compare ! pad-trim both sides on string keys+fields; seed→inv key/field aliases declarative via snapshot_map (or package defaults), v1 {table,entity} rows still load; FK fields resolve inv int IDs → CD via inventory lookup indexes (prefer compare in seed/CD space); first targets Account+Sub for *AcctID/*SubID on ReasonCode/VendorClass-class + PostingClass/CashAccount/OrderType freight; global enums: + per-row field→enum fold REST labels → DAC codes (Usage, Account Type/PostOption, bool_bit Active, CreditTerms Due/Disc/Visible, …); decimal-looking strings collapse trailing zeros (0 vs 0.000000) without mangling bare CDs like 000000; never silent promote config/ (V36)
id|status|task|cites
T91|x|docs rest-api.md — document 26.x GET /entity wrapper + array as legacy when still seen (gh #20)|V12,V31
T92|x|optional: config check ERP probe from wrapper version.acumaticaBuildVersion when present (gh #20; T76 follow)|V12,V27,I.cmd,T76
T93|x|snapshot engine — view model + capture render/parse + determinism rules + write/diff/assert paths|V32,V33,V10
T94|x|acu snapshot CLI — selection like apply/diff/run; --out default snapshots/; --diff; --assert-unchanged; --dry-run; exit matrix; verb-map + V27 allowlist extras|V9,V15,V16,V27,V32,I.cmd,T93
T95|x|entity: + gi: backends — contract REST entity list + OData GI; params $metadata validate fail-closed|V12,V27,V33,T93
T96|x|offline tests: byte-identical twice, order shuffle, schema-tolerance allowlist, float formats → fixed string, key collision exit 1, exit codes|V13,V32,T93,T94
T97|x|distribution scaffold: snapshot/ trial-balance + inventory-summary views; GI defs in config/master; optional cold snapshots/|V28,V33,I.data,T94
T98|x|e2e: after run scenario/, snapshot write; warm re-run + snapshot --assert-unchanged exit 0|V4,V32,T86,T94,T97
T99|x|docs: snapshot vs extract/diff; OData GI expose; idempotence gate recipe|V12,T94
T100|x|snapshot path hard-cut — bare default config/snapshot/; --out default state/; drop root snapshot/ + snapshots/ defaults|V32,I.cmd,I.data
T101|x|scaffold: finance config/snapshot/10-trial-balance.yaml; distribution rehome TB+inventory → config/snapshot/; package templates|V28,V32,I.cmd,T100
T102|x|tests + docs + e2e: path strings config/snapshot + state/; missing-dir message; migration note root snapshot//snapshots/|V9,V13,T100,T101
T103|x|snapshot inquire: backend — contract inquiry PUT $expand=Results; project Results rows via key+capture; optional match filter; params pinned (V33)|V10,V12,V32,V33,I.data,T93,T95
T104|x|package swap golden snapshot views — finance TB + distribution TB/inventory → numeric inquire (or gi) capture; drop roster-only Account/StockItem for those stems|V28,V33,I.data,T97,T101,T103
T105|x|tests + e2e: state/ rows carry EndingBalance / QtyOnHand fixed-point after scenario; offline mock inquire; warm --assert-unchanged still green|V4,V13,V32,V33,T98,T103,T104
T106|x|docs: rest-api + distribution README — inquire: snapshot source; numeric stem rule; GI still optional when V12-verified|V12,V33,T99,T104
T107|x|drop distribution inventory-summary snapshot scaffold — package template + init map + offline/e2e asserts + docs; golden state/ = trial-balance only|V28,V33,I.data,I.cmd
T108|x|drop --flavor CLI + dual templates_for / INIT_FLAVORS; single template map|V28,I.cmd
T109|x|package templates from acumatica-gitops seed trees (config/* + scenario/ + snapshot + meta); drop templates/finance + templates/distribution split; align scenario 20-buy; prune non-seed extras|V28,V29,I.data,T108
T110|x|offline tests + e2e: single full scaffold; drop flavor-param coverage; e2e still green|V13,V28,T108,T109
T111|x|docs sweep: README + retire/merge distribution flavor docs + help text; next-step cmds apply config/|V12,V28,T108,T109
T112|x|rename CLI verb snapshot→state; module snapshot.py→state.py; hard-cut drop snapshot alias|V15,V32,I.cmd,T94
T113|x|defaults config/views/ + state/; package template rehome config/snapshot→config/views; init map + scaffold path|V28,V32,I.data,I.cmd,T112
T114|x|check-extras V15/V27/V28/V32/V33 verb+path; tests + e2e + docs README migration note hard-cut|V9,V12,V13,V15,T112,T113
T115|x|extract hard-cut emit config/; rename extract_manifest.yaml→seed_catalog.yaml; rehome catalog file paths; offline path tests|V30,V34,I.cmd,I.data
T116|x|catalog completeness offline: template seed set equals catalog file set; unit gate|V34
T117|x|catalog rows for full packaged seed incl master; filter-split multi-file entities; include/strip archaeology large Default surfaces|V34,V22,I.data
T118|x|drop WeightUOM/VolumeUOM + other not-returned packaging claims from templates/seed; prove diff config/ green path on LAB5-class|V22,V34,B26
T119|x|e2e full extract round-trip: apply → extract → apply on B → diff config/ clean → re-extract byte-identical incl master|V4,V22,V24,V34,T115,T117,T118
T120|x|docs + help + changelog: extract inverse of apply under config/; entity map = catalog mirror; root-emit migration note|V12,I.cmd,T115
T121|x|extract per-row progress stdout matching apply banner (path -> tenant on url (entity)) + keep skip/write/would-write outcome + end summary|V9,I.cmd
T122|x|strip # comments from package template YAML — scope: src/acumatica_cli/templates/**/*.yaml grep ^\s*# + trailing \s#\s; data only; offline unit gate no-comment; narrative stays sibling gitops separate files|V28
T123|x|friendly network/transport errors — map httpx TransportError class @ main choke → one x line (class + base_url + reachability hint); covers all data-plane via main (apply/diff/run/state/extract/bootstrap/config-check REST); offline unit tests connect/timeout; ACU_DEBUG still re-raises|V9,I.cmd
T124|x|default ACU_SSH from ACU_BASE_URL host — Administrator@<hostname> when key absent; blank key = hosted opt-out; drop ACU_SSH from scaffold env; docs + tests + check-extras V3; verify make check|V3,I.cfg,I.cmd
T125|x|api_version from target.yaml — load_instance flag ? else default_api else 25.200.001; drop ACU_API_VERSION env+scaffold; source-merge over dual-source match; check/show source; offline tests|V27,V2,V3,I.cfg,I.cmd,I.env
T126|x|docs sweep: README + rest-api + templates env/README + target template comments — sole data-repo Default pin = target.yaml default_api; no operator ACU_API_VERSION pin; changelog note|V12,V27,I.cmd,T125
T127|x|SnapshotArtifact IR parse — SM203520 XML ZIP + ac.exe export xml folder; reject .adb; deterministic table/row model + summary fields|V10,V35,V37,I.cmd,I.data
T128|x|acu inventory CLI — artifact → inventory/ summary + tables; --out/--force/--dry-run; offline no session; exit 0/1; V15 verb map + V27 allowlist extras as needed|V9,V15,V16,V35,V37,I.cmd,T127
T129|x|acu reconcile CLI — inventory + optional config/ → findings/ only; never write config/; unmapped + REST gaps + rest-vs-snapshot deltas|V9,V15,V16,V35,V36,I.cmd,I.data,T128
T130|x|offline tests: parse fixtures ZIP+folder, inventory byte-stable, binary reject, version pin vs target.yaml, reconcile findings, apply/diff ignore inventory/findings paths|V13,V35,V36,V37,T127,T128,T129
T131|x|docs + help + changelog: inventory vs extract vs state; dual-reader layout; ac-exe export + SM203520 Settings XML note; README CLI map|V12,V35,I.cmd,T128,T129
T132|x|M2 pad-trim string keys+fields on reconcile join/compare both sides|V38,I.cmd,T129
T133|x|M3 declarative seed→inv key/field aliases in snapshot_map; Subaccount+UnitsOfMeasure join inventory; v1 map loads|V38,I.data,T132
T134|x|M4 FK CD↔ID resolvers from inventory (Account+Sub first); ReasonCode+VendorClass *AcctID/*SubID false deltas → 0 when live matches seed|V38,I.data,T133
T135|x|offline unit tests trim+alias join+Account/Sub resolve; LAB5-style fixture ReasonCode+VendorClass CD/ID pairs → 0 when match; existing reconcile tests green|V13,V38,T132,T133,T134
T136|x|docs/changelog snapshot_map beyond table→entity (aliases+resolvers)|V12,I.data,T133,T134
T137|x|M5 declarative enums: global enums: + per-row field→enum; ReasonCode.Usage + Account Type/PostOption/Active/RequireUnits + CreditTerms/StatementCycle/bool Active package defaults; label→code before compare|V38,I.data,T136
T138|x|M4 extend PostingClass *AcctID/*SubID + CashAccount Account/Sub/Branch + OrderType Freight resolves in package map|V38,I.data,T134,T137
T139|x|offline unit tests: enum Usage/bool/Type + PostingClass FK resolve + decimal trailing-zero norm; package map asserts; existing reconcile tests green|V13,V38,T137,T138
T140|x|docs/changelog/help: enums layer on snapshot_map; package defaults cover LAB5 high-churn enums + PostingClass|V12,I.data,T137,T138
T141|x|tenant list banner hostname-only + offline assert (no full URL in title)|V9,I.cmd,B27
T142|x|acu tenant recycle CLI — wire TenantManager.recycle_app_pool; confirm + --yes; V15 verb map + help; stdout progress; exit 0/1|V1,V5,V6,V9,V15,V16,I.cmd
T143|x|offline tests: recycle invokes Restart-WebAppPool; empty ACU_SSH hard error; confirm/--yes; no REST|V13,V1,T142
T144|x|docs + client mismatch hint → acu tenant recycle; changelog|V12,T142
id|date|cause|fix
B9|2026-07-10|contract-API list GET = optimized export — 500s when any field in scope maps to a BQL-delegate view (Bootstrap Currency GL fields -> CuryRecords); PUT persists but diff cannot read back|V4
B10|2026-07-10|T31 file naming left apply order to alphabetical accident — currencies.yaml 422 on SUB refs from later-sorting subaccounts.yaml; extract-derived accounts.yaml kept AccountGroup refs absent on fresh tenant|V22
B11|2026-07-10|extract-derived accounts YAML carried server-derived fields (ChartOfAccountsOrder, CashAccount) — PUT-tolerated, server keeps own derivation, diff dirty forever|V22
B12|2026-07-10|T34 verify leg "GL batch posts" assumed ledger + GL prefs close GL posting — live chain also needs org-ledger link (Default Ledger.Companies PUT 200 yet link silently dropped) + FinYearSetup + master + company calendar; V17-class unsatisfiable criterion|V17
B13|2026-07-10|period activation (Inactive->Open) = PXFilteredProcessing screen GL503000, not contract-endpoint drivable (Open action redirects via PXRedirectHelper) — T36 verify assumed the endpoint idiom closed the whole GL-posting chain|V17
B14|2026-07-10|multi-view entity composite key: cross-view $filter AND → 200 [] while each predicate alone matches, key-URL GET → non-B9 500 — diff phantom-drifts "missing on tenant" behind succeeded PUT (Bootstrap LedgerCompany)|V4
B15|2026-07-10|init template set not feature-closed — 10-subaccounts.yaml template needs SubAccount, features.yaml template ships built-in six only; scaffolded provision 403s GL203000 @ first baseline file|V22
B16|2026-07-10|T39 verify leg "GL batch posts" while template set (11 → 14) omits org-ledger link — B12-recorded chain link outside scoped file set; scaffolded batch PUT would 422 (caught @ build plan, pre-code)|V22
B17|2026-07-10|T45 retired provision w/o re-routing T8-recorded recovery role (resumable provision = existing-tenant republish route) — Company stranded pre-T37 Bootstrap, data-repo make diff red since 1.4.0 bump|V17
B18|2026-07-11|T53 premise ".env.gpg untracked + unignored" authored while file tracked since 8ad8441 — repo state never probed @ amend; gitignore-only leg = ignored-but-tracked, green verify over standing concern|V17
B19|2026-07-11|extract loop no per-row isolation — first raising row aborts every later manifest row (entity, setup synth, features); virgin-tenant PXSetupNotEnteredException 500 hard-aborts while siblings answer 200 []|V24
B20|2026-07-11|tenant create --type free string forwarded verbatim to ac.exe CompanyConfig — any string accepted, unknown dataset name inserts nothing, virgin tenant lists as Demo (gh issue #6)|V16
B21|2026-07-11|B14 workaround under-keys LedgerCompany (keys: [LedgerCD]) — multi-org tenant 3 org links per ledger, extract emits records dup on declared key; no layer audits key-tuple uniqueness; diff permanent false drift exit 2, apply would collapse org links to one|V25
B22|2026-07-12|GL201100 org-scoped views (Bootstrap CompanyPeriod/CompanyCalendar) answer 200 [] on multi-org tenant — contract session carries no org context; extract skips 30-open-periods w/ false warning, diff calendar done_when probes never fire; single-org e2e masked class (gh issue #8)|V26
B23|2026-07-13|diff key $filter string-quoted every literal — boolean-keyed baseline (Bootstrap INPreferences keyed HoldEntry) reads back 500 incompatible-types Edm.Boolean/Edm.String; numeric keys same class; latent until T61's boolean-keyed setup singletons|V4
B24|2026-07-17|virgin-tenant single-session apply opens REST session pre-Company; Company PUT mid-run leaves branch selectors empty → INPreferences TransitBranchID 500 'Branch' cannot be empty; new session succeeds w/ no seed change (gh #16)|V5
B25|2026-07-25|InventorySummaryInquiry warehouse-only params → empty Results; Results omit InventoryID → false-empty state/inventory-summary|V28,V33
B26|2026-07-27|seed claims WeightUOM/VolumeUOM (Company packaging + StockItem) but endpoint GET not return → permanent red diff (B11 class)|V34
B27|2026-07-29|tenant list banner uses full base_url (scheme+path); wraps + shows REST path on SSH-plane list|V9