# GL master-data core — the machine-readable skill contract.
# The human tutorial is posts/enterprise_software/gl/gl-core.md (narrative that references this).
# check:skills binds the two: the post's `skill:` id must resolve here, its example
# param values must equal these defaults, and its tested badge must match tested_branches.
# Lives at skill/enterprise_software/gl/core.skill.yaml; served over MCP at dodil://skills/gl/core;
# executed by `dodil skill scaffold gl/core`.

id: gl/core
version: 1.0.0
summary: >-
  Scaffold the General Ledger master-data core on one DataK3 bucket — the chart of accounts,
  the account-hierarchy graph (parent/child rollup), and the shared journals / journal_lines / ledger
  table shapes every GL workflow writes — then post ONE balanced opening-balance journal that seeds the
  ledger so the trial balance nets to 0.00 from day one. Money is DECIMAL(18,2) (never double); every
  write is idempotent (INSERT … ON CONFLICT (<pk>) DO UPDATE, or data_table_upsert). The system of
  record every other gl/* workflow (journal-entry, period-close, financial-reporting,
  subledger-reconciliation, multi-currency) consumes.
module: gl
category: enterprise-software
workflows: [chart-of-accounts, account-hierarchy, opening-balances]
pillars: [sql, graph]
overlays: [finserv, manufacturing]        # cross-industry base; overlays add regulated-close / cost-accounting diffs

# Binding to the narrative (check:skills verifies these resolve + agree)
post: posts/enterprise_software/gl/gl-core.md
route: /library/gl-core

params:
  bucket:              { type: string,  default: gl,   prompt: "Bucket name?" }
  functional_currency: { type: string,  default: USD,  prompt: "Functional (reporting) currency?" }
  coa_preset:          { type: enum,    options: [demo, empty], default: demo }
  hierarchy:           { type: boolean, default: true }   # account_edges (child_of) + CREATE GRAPH gl_accounts
  opening_balances:    { type: boolean, default: true }   # post journal 1 + materialize the ledger

provides:
  tables:  [accounts, journals, journal_lines, ledger, account_edges]
  owns_master: [accounts, journals, journal_lines, ledger]
  graphs:  [gl_accounts]             # created standalone here; deferred (created once) in the suite
  vectors: []                        # GL is SQL + graph only — no vector pillar at core
consumes: []                         # core is the root of the GL DAG — it owns everything

# THE finance-critical correctness rules every gl/* skill obeys (stated so builders can't drift):
#  - MONEY = DECIMAL(18,2). NEVER double — float drift breaks the balance invariant. (FX rate cols, in
#    gl/multi-currency, are DECIMAL(18,6).) VERIFIED LIVE: 0.10+0.20+0.70 SUM to exactly 1.00, no float tail.
#  - IDEMPOTENT WRITES = INSERT … ON CONFLICT (<pk>) DO UPDATE SET c=EXCLUDED.c, or data_table_upsert.
#    A bare re-INSERT of a committed PK raises duplicate-key SQLSTATE 23505 — it is NOT an upsert.
#    Opening-balance re-posts and ledger re-materialization MUST be idempotent (retry / shard replay safe).
#  - The BALANCE INVARIANT: every posted journal has SUM(debit) = SUM(credit); the trial balance
#    (per-account SUM(debit) - SUM(credit), grand total) nets to exactly 0.00.
#  - ledger.balance is the SIGNED net (SUM(debit) - SUM(credit)) per account: debit-normal accounts carry a
#    positive balance, credit-normal a negative one; SUM(ledger.balance) over the whole ledger IS the trial
#    balance and must be 0.00. This keeps every balance additive and O(1) to read.

# The ordered scaffold plan. Each step's `tools` must resolve against dodil://commands (check:skills).
steps:
  - id: stub_masters
    title: The masters — core IS the system of record (nothing to stub)
    when: standalone
    tools: [data_bucket_create]
    detail: >-
      gl/core OWNS every master (accounts, journals, journal_lines, ledger) + the account_edges hierarchy,
      so it has no consumes and no stub to build — this is the step every OTHER gl/* skill defers to.
      A workflow skill installed WITHOUT core ships its own `when: standalone` stub_masters step that
      recreates just the masters it reads (same column defs, money DECIMAL(18,2), every non-key column
      nullable:true); when two workflows share masters, install gl/core once here instead of divergent stubs.
      Shared-type contract the whole suite agrees on: journal_lines is composite-PK (journal_id, line_no) with
      debit/credit DECIMAL(18,2); journals.status is draft|posted|reversed; accounts.type is
      asset|liability|equity|revenue|expense with normal_balance debit|credit. A stub that types money as
      double ships float drift, so core and every deferring skill agree on DECIMAL(18,2).
  - id: tables
    title: Stand up the master tables (accounts + journals/journal_lines/ledger)
    when: always
    tools: [data_bucket_create, data_table_create]
    detail: >-
      Create bucket {{bucket}} + accounts(key account_id) with name, type, normal_balance,
      parent_account_id(long), currency, active(boolean); journals(key journal_id) with journal_date(timestamp),
      period, source, status, memo, reverses_journal_id(long); journal_lines(composite key journal_id,line_no)
      with account_id(long), debit DECIMAL(18,2), credit DECIMAL(18,2), line_memo; ledger(key account_id) with
      balance DECIMAL(18,2), currency, as_of(timestamp). --merge-key required (idempotent upserts; re-runs +
      shard retries are safe). data table create defaults non-PK columns to NOT NULL — set nullable:true on
      every optional column (all non-key columns here) or a draft journal / opening line 500s.
  - id: chart_of_accounts
    title: Seed the chart of accounts
    when: { coa_preset: demo }
    tools: [data_table_upsert]
    detail: >-
      Upsert a realistic COA: 5 roots (1000 Total Assets, 2000 Total Liabilities, 3000 Total Equity,
      4000 Total Revenue, 5000 Total Expenses; parent_account_id null) + 12 posting leaves under them —
      assets 1100 Cash / 1200 Accounts Receivable / 1500 Fixed Assets; liabilities 2100 Accounts Payable /
      2200 Accrued Liabilities; equity 3100 Common Stock / 3200 Retained Earnings; revenue 4100 Product /
      4200 Service; expenses 5100 COGS / 5200 Salaries / 5300 Rent. Each leaf carries type + normal_balance
      (asset/expense=debit, liability/equity/revenue=credit), currency {{functional_currency}}, active true.
      17 accounts total: asset 4, liability 3, equity 3, revenue 3, expense 4. Never "" / null in account_id
      (a keyed upsert drops null/empty keys).
  - id: hierarchy
    title: Build the account-hierarchy graph
    when: { hierarchy: true }
    tools: [data_table_upsert, data_pg]
    detail: >-
      Populate account_edges(src,dst,rel) with the 12 child_of edges (src=child leaf, dst=parent root),
      THEN CREATE GRAPH gl_accounts NODES (accounts KEY account_id) EDGES (account_edges SRC src DST dst).
      The graph snapshots edges at creation, so insert ALL edges first. Because edges point child->parent,
      the descendants of a rollup account are the INCOMING direction: graph_khop('gl_accounts', 1000, 5, 'in')
      returns Total Assets' three leaves (Cash/AR/Fixed Assets) — the basis for financial-reporting's
      account-type rollup. In the SUITE, core inserts nodes+edges but DEFERS the single CREATE GRAPH to the
      last skill so nothing is snapshotted twice.
  - id: opening_balances
    title: Post the balanced opening-balance journal + materialize the ledger
    when: { opening_balances: true }
    tools: [data_table_upsert, data_pg, data_sql]
    detail: >-
      Upsert journals row 1 (status posted, source opening-balance, period 2026-09), then its
      balanced journal_lines: debit the asset leaves, credit the liability + equity leaves, so
      SUM(debit) = SUM(credit) exactly (VERIFIED: 500000.10 + 150000.20 + 349999.70 = 1000000.00 debit =
      1000000.00 credit, balance 0.00). BEFORE treating the journal as posted, assert SUM(debit)=SUM(credit).
      Materialize the ledger idempotently: INSERT INTO ledger SELECT account_id, SUM(debit)-SUM(credit),
      '{{functional_currency}}', <as_of> FROM journal_lines GROUP BY account_id ON CONFLICT (account_id)
      DO UPDATE SET balance=EXCLUDED.balance, as_of=EXCLUDED.as_of. The trial balance
      SUM(ledger.balance) then nets to 0.00. Re-posting the lines (ON CONFLICT) + re-materializing leaves the
      counts unchanged and the balance at 0.00 — the idempotency proof; a bare re-INSERT would 23505 instead.

# The Q&A an agent runs with the user (was `## Customize`). Each answer's effect = the best-practice encoding.
customize:
  - param: coa_preset
    ask: "Load the demo chart of accounts + a balanced opening-balance journal, or ship empty schemas?"
    effects:
      demo:  "loads 17 accounts (5 roots + 12 leaves) + journal 1 (Σdebit=Σcredit=1000000.00) + the seeded ledger; ## Test asserts the account-type counts, the Total Assets rollup, and trial balance = 0.00."
      empty: "schemas + graph tables only, no journal; ## Test switches to structural assertions (tables exist, graph traverses 0 rows without error, ledger empty)."
  - param: functional_currency
    ask: "What is the functional (reporting) currency the ledger is kept in?"
    effects:
      USD: "every account + ledger row carries this ISO currency; gl/multi-currency later revalues foreign-currency balances INTO it. Change it once here and every downstream skill inherits it."
  - param: hierarchy
    ask: "Build the account-hierarchy graph (account_edges + CREATE GRAPH gl_accounts)?"
    effects:
      "true":  "builds the child_of edges + the gl_accounts graph — the parent/child rollup is what financial-reporting sums account types over; keep it on."
      "false": "flat COA, no account tree, no graph rollup — reporting must GROUP BY type instead of traversing."
  - param: opening_balances
    ask: "Post the opening-balance journal and seed the ledger?"
    effects:
      "true":  "posts journal 1 (balanced) + materializes the ledger so the trial balance nets to 0.00 from day one."
      "false": "empty ledger — the first real journal (via gl/journal-entry) opens the books instead."

# What proves it (was `## Test`) — assertions the CI replay runs on the default branch.
# Live-validated 2026-09-02, org IHDIASH, bucket glb-core-8x4q (torn down). Real returned values inline.
tests:
  - "data table list -> 5 tables (accounts, journals, journal_lines, ledger, account_edges)"
  - "count(*) FROM accounts = 17; by type -> asset 4, liability 3, equity 3, revenue 3, expense 4"
  - "account_edges = 12 (all child_of); journals = 1 (the opening journal)"
  - "graph_khop('gl_accounts', 1000, 5, 'in') JOIN accounts -> Cash(1100), Accounts Receivable(1200), Fixed Assets(1500) — Total Assets' three descendant leaves (Bolt MATCH (root)<-[:account_edges*1..5]-(child) agrees)"
  - "opening journal balances: SUM(debit) = 1000000.00, SUM(credit) = 1000000.00, SUM(debit)-SUM(credit) nets to 0 (exact, no float tail — 0.10+0.20+0.70 = 1.00)"
  - "ledger materialized -> 7 rows (the posted leaves); trial balance SUM(balance) = 0.00"
  - "idempotent re-run: a bare re-INSERT of (journal_id,line_no)=(1,1) raises SQLSTATE 23505; re-posting the 7 lines with ON CONFLICT + re-materializing the ledger leaves lines=7, ledger_rows=7, journal balance 0.00, trial balance 0.00 (no double-post)"
  - "data connect {{bucket}} -> prints pg.uk-lon-1.dodil.io:5432/{{bucket}} + bolt + grpc"

tested_branches:
  - { coa_preset: demo, functional_currency: USD, hierarchy: true, opening_balances: true, tested_at: 2026-09-02 }   # anchor — full replay incl. trial balance = 0.00 + idempotent re-run
  - { coa_preset: empty, hierarchy: true, opening_balances: false, tested_at: 2026-09-02 }                            # structural (same DDL; derived)

# Ship-it contract.
# NOTE (2026-09-02): gl/core is DECLARATIVE — schema + the account-hierarchy graph + a seeded opening-balance
# journal + a materialized ledger, all produced at author time by pure data ops (data_table_create /
# data_table_upsert / data_pg). There is NO Ignite handler and NO Models anywhere in gl/core (core owns data,
# not judgement — and GL is pure SQL, no embeddings). The bucket IS the running ledger the moment the rows land,
# queryable over pg / bolt / grpc (data connect). The stateful pieces are the workflow engines — the
# gl-posting-engine (gl/journal-entry's idempotent ON CONFLICT poster), the period-close engine, the reval
# engine — each an IMAGE-mode Ignite app (Lane B: a Dockerfile + --dockerfile-path, Kaniko build-on-deploy,
# HTTP server on $PORT with GET /healthz + a POST route; NOT --runtime python). A pure-SQL GL engine calls NO
# Models, so its service account needs only k3.editor + ignite.app-developer (NO ignite.model-user).
# DODIL_SERVICE_ACCOUNT_ID = the cli-... serviceAccountId from `auth service-account create`, NOT the uuid.
deploy:
  app: none            # core is declarative — no workload to deploy
  mode: none
  data_plane: "pg-wire pg.uk-lon-1.dodil.io:5432 (db = bucket, user = token, password = login/SA token) — SQL + graph_khop() + Bolt over one connection; idempotent write = INSERT … ON CONFLICT (<pk>) DO UPDATE (a bare re-INSERT of a committed PK raises duplicate-key 23505); no k3.dodil.io HTTP API"
  models: []           # GL is pure SQL — no Models at core or in any pure-SQL GL engine
