Skip to content

Latest commit

 

History

History
176 lines (162 loc) · 48.7 KB

File metadata and controls

176 lines (162 loc) · 48.7 KB

SPEC — acu CLI (acumatica-cli)

§G GOAL

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 createacu applyacu diff → byte-identical configured tenant; optional acu state commits derived-state observations under state/; warm acu state --assert-unchanged proves scenario idempotence.

§C CONSTRAINTS

  • Repo = acu CLI only. Data (baseline/, .env.gpg) lives in separate data repos (sibling acumatica-devops); infra = sibling acumatica-infra; blog = sibling acumatica-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; entry acu.
  • Default suite fully offline: SSH = monkeypatched subprocess.run, REST = httpx.MockTransport; no live instance needed; make check gate offline-only. Opt-in live tier: pytest marker e2e, deselected via addopts, runs only via make e2e; e2e self-contained — single-org synthetic company scaffolded from packaged config init templates into tmp dirs, no repo-root data symlinks, no dataset tenants (SalesDemo|T100|U100 stay 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 diff or make e2e here (self-contained per the e2e constraint; .env/.env.gpg real files @ repo root).
  • Unconfigured tenant fails most entities (500 PXSetupNotEnteredException or 403 feature-gated) until bootstrap (features + company/branch); built-in endpoints can't bootstrap; PUT CompaniesStructure dead; payment terms have no Default-endpoint entity; financial currency (CM202000) has none either — REST Currency = 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 or ac.exe export xml table-XML folder (ingress only); table/DAC = artifact identity; inventory/ = offline IR product; map join = table↔entity (snapshot_map.yaml table→entity or identity match), never seed-name↔inventory-name; REST extract = seed reader; offline inventory = artifact reader; sole tenant mutator = REST apply; CLI never creates/restores SM203520; ac.exe has no snapshot create — consume artifact only; derived-state = acu state (I.cmd), not inventory not tenant-snapshot; bare "snapshot" ! synonym for state (V32 rename)
  • config init = single full seed scaffold (LAB5/gitops-class under config/ + lifecycle scenario/ + observer config/views/); package templates derive from sibling acumatica-gitops seed trees (prune extras as needed — demo/, Makefile, live .env, committed state/ 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).

§I INTERFACES

  • 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 (.env or process), code default — exclusion api_version: --api-version flag ? → else data-repo target.yaml default_api when present → else code default 25.200.001 (no ACU_API_VERSION env pin); creds: flag, env, admin default (username only); --version → editable install (PEP 610 direct_url.json dir_info.editable) renders <version>+dev (<checkout path>), release install plain <version>; --completion → emit click completion script (bash|zsh|fish; ? → $SHELL basename, 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_SSH non-empty post-default ! else hard error naming key before any remote); list → table title Tenants on <hostname> (host from base_url via 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: choice SalesDemo|T100|U100 (V12-verified dataset folders on box; System excluded — 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, --yes skips; recycle → Restart-WebAppPool for instance (kills all sessions → free concurrent API-user license slots; also reloads tenant map per V5); site-wide (no --id); confirm prompt, --yes skips
  • cmd: acu bootstrap [--export <path>] → publish AcuBootstrap into session tenant (data plane, /CustomizationApi); tenant ! (--tenant/ACU_TENANT); features + contract via dual bootstrap resolve (prefer config/bootstrap/{features.yaml,project.xml} then root bootstrap/…; absent → built-in six / packaged full company Bootstrap/1.0.0); content-digest skip per V4; ACU_SSH non-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 (skip features.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-repo config/ has any SEED_DIRS child → only config/<name>/ for present names, else root <name>/ (legacy root); dual layout → prefer config/ only, never merge both; none exist → error exit 1 naming dirs; dry-run lines would PUT …, summary suffixed (dry run)
  • cmd: acu diff [files|dirs] → GET by $filter on key fields, compare normalized; args ? + dir expand = same rules as apply (umbrella + prefer config/); optimization-500 (delegate-view fields) → retry record via key-URL GET /<entity>/<key1>[/<key2>...] (YAML key order); per-file progress on stdout matching apply banner (path -> tenant on url (entity)) + summary N record(s) ok or N 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; capture resolves 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 ! present inquire-absolute gate (author params + match ? + when field eq|gte) before steps — probe true → stdout skip <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 under config/{bootstrap,baseline,setup,master}/ (hard-cut; no root SEED_DIRS emit; no --layout); catalog-driven — package data seed_catalog.yaml per row: entity, endpoint (symbolic bootstrap → active package version @ load per V20, or default|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 $filter fragment → both list reads: plain GET + B9 $select fallback), features gates, kind ? (setup synth); features emit config/bootstrap/features.yaml; --out ? cwd, created if absent; per-file skip-if-exists → skip <path> (exists), --force overwrites; zero live records → skip <path> (no records), no file; --only filters 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 lines would write <path> (<n> records); per-row failure isolation per V24 — row failure → x <name>: <reason> + continue, PXSetupNotEnteredException 500 → skip <path> (screen setup not entered), run ends w/ summary; emitted file ! parse via load_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 under inventory/ (default --out); artifact = SM203520 XML ZIP (manifest.xml + *.xml) or ac.exe export xml table-XML folder — both normalize one IR (V37); binary .adb fail-closed; no REST no SSH no password; emit summary.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 under findings/ (default --out findings/) from inventory tree + optional existing config/ seed tree (prior extract); v1 never writes config/ (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 via snapshot_map or 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 per snapshot_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 under state/; args ? → config/views/ hard-cut (no config/snapshot/ fallback); dir expands *.yaml alpha; explicit paths win; default write --out (default state/); --diff compare live vs disk write nothing exit 0 either way; --assert-unchanged as --diff + exit 2 if moved; --dry-run resolve 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, never endpoint: symbols, never apply path; views live under config/views/ but ! SEED_DIRS; ! SM203520 tenant snapshot; ! inventory/findings paths (V35); ! extract --only …; run expect:delta stays 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 derived ACU_SSH, flag overrides reflected; never emit ACU_API_VERSION — api pin not env); ACU_PASSWORD never emitted any form → output round-trips (acu config show > .env reloads identical, password excluded); when data-repo target.yaml present → surface erp/default_api as comments + note api_version sourced from default_api when flag absent (still exit 0); ! same Instance resolution path as live cmds — no parallel resolution; no target hard gate
  • cmd: acu config init [--host <host>] [<dir>] → scaffold data repo: .env ACU_* placeholders omit ACU_SSH + ACU_API_VERSION (--host substitutes host into ACU_BASE_URL only, placeholder erp.example.com; key absent → default Administrator@+base_url host; hosted opt-out = present blank ACU_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), omit ACU_API_VERSION line; committed target.yaml (erp + default_api = sole Default contract pin when present; scaffold defaults match CLI-verified matrix); no --flavor — single full template under config/{bootstrap,baseline,setup,master}/ + scenario/ + config/views/10-trial-balance.yaml + short README; package content ! derive from sibling acumatica-gitops seed trees (prune non-seed extras: demo/, Makefile, live .env, committed state/); bootstrap company + features + credit-terms + project.xml full company Bootstrap/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 + authored present) + 20-buy.yaml + 30-build.yaml + 40-sell.yaml (gitops names; monoscenario buy-sell forbidden); state TB EndingBalance-class numeric money capture per V33 (inventory-summary ! golden); post-scaffold next-step cmds prefer bootstrap, 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 via importlib.resources; no git init, no gpg
  • cmd: acu config check [--strict] → read-only preflight, dependency order, one ok/fail/warn/skip line each (stdout via output.data): discovery (walk-up finds .env + parse + ACU_BASE_URL) → secrets (ACU_PASSWORD resolved) → target (local: present → load ok + Instance.api_version from default_api unless --api-version flag; missing under data root → warn, --strictfail; invalid → fail; no data root → skip or omit) → REST (login + landed-tenant verify + logout) [→ endpoints Default presence via dual-shape GET /entity parse 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 no fail (warns allowed); exit 1 any fail; 2 stays drift
  • cfg: Instance = pydantic-settings BaseSettings, env prefix ACU_, keys: ACU_BASE_URL (REST root: scheme + host + site path), ACU_SSH (control plane user@host: flag/env when key present wins incl blank; key absent + base_url host → code default Administrator@+hostname; key present blank → empty = hosted/data-plane only; tenant cmds hard-error when empty post-default naming key), ACU_TENANT, ACU_USER (default admin), ACU_PASSWORD; field api_version (version half only — / or Default/ prefix → hard error; name half hardcoded Default — custom endpoints = per-call seed endpoint: overrides) ! resolved outside env: --api-version flag ? → else target.yaml default_api when present → else code default 25.200.001; never ACU_API_VERSION env key (unknown ACU_* ignored); other flags override env per key; install-layout values = config.py constants (AcumaticaERP — IIS pool coupling documented @ recycle_app_pool call site, C:\Acumatica\AcumaticaERP, ac.exe path, AcumaticaDB), not config surface
  • env: .env ? — sole config file: where + secrets as ACU_* vars; discovery sentinel, walk-up per V3; encrypted at rest as .env.gpg; absent → resolution runs on flags + process environment alone; password ! resolve via --password or ACU_PASSWORD; username ? via --username or ACU_USER, default admin; never holds dataset target matrix or Default API pin (V2/V27 — no ACU_API_VERSION)
  • data: baseline/*.yamlentity / key (string or list) / records + endpoint ? (literal e.g. Bootstrap/1.0.0|Default/25.200.001 or symbolic bootstrap → active package version @ load per V20, or symbolic defaultDefault/<Instance.api_version> @ HTTP via client._url per V20); parsed by seed.py
  • data: master/*.yaml → same seed shape as baseline (entity/key/records/endpoint ?); default apply/diff after setup/; init scaffolds under config/master/
  • data: setup/*.yaml → action file: action / entity / record + parameters ? + endpoint ? (literal or symbolic bootstrap|default per V20) + done_when ({entity?, endpoint?, filter?}; entity+endpoint default to action's); one record per file — multi-invocation = multiple numbered files; realized POST /entity/<endpoint>/<Entity>/<Action> body {entity, parameters} → 204 done or 202 poll Location; parsed by seed.py dispatch on action: key — apply/diff absorb kind transparently
  • data: target.yaml → ? committed verified matrix @ data-repo root: erp (claimed product line/build) + default_api (Default version half only, never Default/... path; sole data-repo source of Instance.api_version when --api-version flag absent); present → load per V27 (sources api_version + erp claim) on allowlisted data-plane cmds + config check; absent → legacy warn on config check unless --strict + code-default api_version; invalid → hard fail any loader; never secrets (V2)
  • data: bootstrap/features.yaml or config/bootstrap/features.yaml → ? list of FeaturesSet property names (DAC bit flags) → plugin Enabled set @ package build; resolve prefer config/bootstrap/ then root bootstrap/; absent → built-in six (FinancialModule, FinancialStandard, DistributionModule, Inventory, Branch, MultiCompany)
  • data: bootstrap/project.xml or config/bootstrap/project.xml → ? endpoint contract (entities, fields, name+version identity); resolve prefer config/bootstrap/ then root; present → package_zip uses it; absent → packaged full company contract (Bootstrap/1.0.0 surface: 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 scaffolds config/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 under config/ but ! SEED_DIRS (! apply/diff/extract seed)
  • data: seed_catalog.yaml → packaged extract registry (rename from extract_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 (each id + one op key of put|action|wait|get + record ? + capture ? + wait ?; empty steps ok — stub no-op) + expect ? + once ? (true ! present: inquire + parameters author-owned incl Period + match ? + when per-field eq|gte absolute gate; no filename convention; no CLI capital default; no ${var} in present — probe pre-steps) ; transactions never keyed upserts — run owns them, apply/diff never read scenario/; golden set = 10-seed-capital + 20-buy + 30-build + 40-sell (gitops names); parsed by run.py
  • data: config/views/*.yaml → view def (observer config, ! seed): name (output stem under state/) + source (exactly one of gi:|entity:|inquire: + params ? pinned in YAML; inquire: = contract inquiry entity name) + key (sort + identity cols, unique across rows) + capture allowlist + decimals ? default 2; stem trial-balance ! ≥1 numeric money field in capture (V33); inventory-summary ! golden this pass; alphabetical prefix order like scenario/; bare default hard-cut this tree (no config/snapshot/ fallback); apply/diff never read config/views/; parsed by state engine
  • data: state/*.yaml → observation file (committed derived-state evidence, ! seed): view + erp (live build provenance) + rows flow-style one mapping per line (key-sorted; money = fixed-point strings @ view decimals); never under SEED_DIRS; never endpoint:; never apply path; series history = git log -p state/ — no in-tool baseline/chart; hard-cut default --out state/
  • data: inventory/ → SnapshotArtifact emit (! SEED_DIRS, ! seed): summary.yaml (erp/build, export mode, table row counts) + tables/<Table>.yaml (columns + rows); never endpoint:; apply/diff/extract never load; engagement-generated — config init does 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 init does not scaffold (V35/V36)
  • data: snapshot_map.yaml → ? package table→entity map (catalog entity name; sibling spirit of seed_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 writes config/; promote path post-v1; ! V34 catalog completeness surface
  • api: /entity/Default/<api_version>/ (default 25.200.001) → cookie-session httpx; values wrapped {"Field": {"value": ...}}; PUT = keyed upsert
  • ssh: ac.exe -cm:CompanyConfig + sqlcmd over ssh → remote shell PowerShell; exit $LASTEXITCODE appended so failures propagate
  • pkg: pypi.org acumatica-clipip install acumatica-cli; publish = GitHub Actions release.yml on tag v* after reusable CI check passes; trusted publishing (OIDC), uv build sdist+wheel + GH release create; no stored API token

§V INVARIANTS

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)

§T TASKS

archived: §T.1..§T.90 → SPEC.archive.md (90 rows)

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 snapshotstate; module snapshot.pystate.py; hard-cut drop snapshot alias|V15,V32,I.cmd,T94 T113|x|defaults config/views/ + state/; package template rehome config/snapshotconfig/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.yamlseed_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

§B BUGS

archived: §B.1..§B.8 → SPEC.archive.md (8 rows)

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