# GL journal-entry — the machine-readable skill contract.
# The human tutorial is posts/enterprise_software/gl/gl-journal-entry.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/journal-entry.skill.yaml; served over MCP at
# dodil://skills/gl/journal-entry; executed by `dodil skill scaffold gl/journal-entry`.

id: gl/journal-entry
version: 1.0.0
summary: >-
  The GL correctness showcase — post a balanced double-entry journal to the ledger, exactly once,
  and reverse it cleanly. Enforce the balance invariant BEFORE posting (reject any journal where
  SUM(debit) <> SUM(credit)); post idempotently (INSERT … ON CONFLICT (journal_id,line_no) DO UPDATE +
  a ledger recompute ON CONFLICT (account_id), so a retry / shard replay / at-least-once redelivery posts
  ONCE, never twice); reverse by a mirror journal (debit and credit swapped, linked by reverses_journal_id)
  that nets the affected accounts back to zero impact; and write an append-only journal_audit event log.
  Money is DECIMAL(18,2) (never double); concurrent posters retry on SerializationFailure. Pure SQL over
  gl/core's accounts / journals / journal_lines / ledger — no Models.
module: gl
category: enterprise-software
pillars: [sql]

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

params:
  bucket:              { type: string,  default: gl,   prompt: "Bucket name?" }
  functional_currency: { type: string,  default: USD,  prompt: "Functional (reporting) currency the ledger is kept in?" }
  audit_log:           { type: boolean, default: true }   # write append-only journal_audit events (posted|reversed)
  concurrency_retries: { type: number,  default: 4 }       # SerializationFailure retry attempts in the posting engine

provides:
  tables:  [journal_audit]           # the append-only posted|reversed event log this skill adds
  owns_master: []                    # posts INTO gl/core's masters; owns no master itself
  apps:    [gl-posting-engine]       # the idempotent ON CONFLICT poster (image-mode Ignite app; shown-as-code here)
  graphs:  []
  vectors: []                        # GL journal entry is pure SQL — no vector, no graph pillar
consumes:
  master:  [{ table: accounts, from: gl/core }, { table: journals, from: gl/core },
            { table: journal_lines, from: gl/core }, { table: ledger, from: gl/core }]

# THE finance-critical correctness rules this skill exists to prove (stated so builders can't drift):
#  - MONEY = DECIMAL(18,2). NEVER double — float drift breaks the balance invariant. VERIFIED LIVE
#    (2026-09-03): the opening cents 0.10+0.20+0.70 SUM to exactly 1.00, no float tail; trial balance = 0.00.
#  - THE BALANCE GATE: a journal may flip to status=posted ONLY when SUM(debit) = SUM(credit) over its lines.
#    An unbalanced journal is REJECTED before it touches the ledger (VERIFIED: a 25000.00/24000.00 journal
#    reports imbalance 1000.00 and is rejected; corrected to 25000.00/25000.00 it reports 0.00 and posts).
#  - 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 (VERIFIED LIVE) — it is NOT an upsert.
#    Re-posting a journal's lines + re-materializing the ledger with ON CONFLICT leaves counts + balances
#    UNCHANGED (the no-double-post proof).
#  - REVERSAL = a mirror journal (debit and credit swapped) linked by reverses_journal_id; the original stays
#    on the books (status=reversed) and the mirror offsets it, so the affected accounts net back to their
#    pre-post balances (net 0.00 impact) and the trial balance stays 0.00.
#  - ledger.balance is the SIGNED net SUM(debit)-SUM(credit) per account over journals with status IN
#    ('posted','reversed'); SUM(ledger.balance) IS the trial balance and must be 0.00.
#  - CONCURRENCY: the tables engine is serializable — wrap every posting write in a SerializationFailure /
#    DeadlockDetected retry loop so parallel posters never corrupt a balance.
#  - APPEND-ONLY events (journal_audit, uuid PK) may use a plain INSERT — a fresh uuid never collides.

# The ordered scaffold plan. Each step's `tools` must resolve against dodil://commands (check:skills).
steps:
  - id: stub_masters
    title: Stub the masters you post into (standalone — skip if gl/core is present)
    when: standalone
    tools: [data_bucket_create, data_table_create, data_table_upsert, data_pg]
    detail: >-
      Create bucket {{bucket}} + the gl/core masters this skill reads/writes, using the LOCKED shapes (money
      DECIMAL(18,2), every non-key column nullable:true — data table create defaults non-PK columns to NOT
      NULL): accounts(key account_id: name, type, normal_balance, parent_account_id long, currency, active
      boolean); journals(key journal_id: journal_date timestamp, period, source, status draft|posted|reversed,
      memo, reverses_journal_id long); journal_lines(composite key journal_id,line_no: account_id long,
      debit DECIMAL(18,2), credit DECIMAL(18,2), line_memo); ledger(key account_id: balance DECIMAL(18,2),
      currency, as_of timestamp). Plus this skill's own append-only journal_audit(key audit_id string:
      journal_id long, event string [posted|reversed], event_at timestamp) — event_at, NOT `at` (a DuckDB
      reserved word). Seed a minimal balanced COA (5 roots + 12 leaves, 17 accounts) + the balanced opening
      journal 1 (Σdebit=Σcredit=1000000.00, status posted) + materialize the ledger (trial balance 0.00) so
      posting has real data. When gl/core is present it OWNS accounts/journals/journal_lines/ledger — skip
      this step and just create journal_audit.
  - id: validate_balance
    title: The balance gate — reject a journal that does not balance
    when: always
    tools: [data_table_upsert, data_pg, data_sql]
    detail: >-
      BEFORE a journal may post, assert SUM(debit) = SUM(credit) over its lines. Stage a candidate journal as
      status=draft, then run the gate: SELECT SUM(debit), SUM(credit), SUM(debit)-SUM(credit) AS imbalance …
      GROUP BY journal_id. An unbalanced journal (imbalance <> 0.00) is REJECTED — it never touches the ledger.
      VERIFIED LIVE: journal 2 staged as debit 25000.00 / credit 24000.00 reports imbalance 1000.00 (rejected);
      correcting line 2 to 25000.00 (via ON CONFLICT upsert) reports imbalance 0.00 (accepted, may post). The
      gate is exact because money is DECIMAL(18,2) — a double column would make 0.1+0.2 read back 0.30000…04
      and the gate flaky.
  - id: post_idempotent
    title: Post idempotently — flip to posted, recompute the ledger, prove no double-post
    when: always
    tools: [data_pg, data_table_upsert, data_sql]
    detail: >-
      Post the balanced journal: flip journals.status to 'posted' (ON CONFLICT (journal_id) DO UPDATE), write
      an append-only journal_audit event (posted), and re-materialize the ledger as the signed net over posted
      journals: INSERT INTO ledger SELECT jl.account_id, SUM(jl.debit)-SUM(jl.credit), '{{functional_currency}}',
      <as_of> FROM journal_lines jl JOIN journals j ON j.journal_id=jl.journal_id WHERE j.status IN
      ('posted','reversed') GROUP BY jl.account_id ON CONFLICT (account_id) DO UPDATE SET balance=EXCLUDED.balance,
      as_of=EXCLUDED.as_of. VERIFIED LIVE: posting journal 2 (Salaries 25000.00 debit / Cash 25000.00 credit)
      moved Cash 500000.10 -> 475000.10 and created Salaries +25000.00; trial balance stayed 0.00 (8 ledger rows).
      THE idempotency proof: a bare re-INSERT of committed line (2,1) raised SQLSTATE 23505; re-posting both lines
      with ON CONFLICT + re-materializing the ledger left journal_lines=2, ledger_rows=8, Cash 475000.10,
      Salaries 25000.00, trial balance 0.00 — no double-post.
  - id: reverse
    title: Reverse a journal — a mirror entry that nets to zero
    when: always
    tools: [data_table_upsert, data_pg, data_sql]
    detail: >-
      Reverse by posting a MIRROR journal (debit and credit swapped) linked by reverses_journal_id; mark the
      original status=reversed (it stays on the books — the mirror offsets it), write a journal_audit (reversed)
      event, and re-materialize the ledger over status IN ('posted','reversed'). VERIFIED LIVE: journal 3
      (reverses_journal_id=2: Salaries 25000.00 credit / Cash 25000.00 debit) netted the affected accounts back
      to pre-post — Cash returned to 500000.10, Salaries to 0.00, net(journals 2+3)=0.00, trial balance 0.00.
      Re-running the reversal is idempotent (same ON CONFLICT writes), so a replayed reversal never double-reverses.
      NOTE — this is ERROR CORRECTION (original was wrong → status=reversed). A RECURRING/SCHEDULED ACCRUAL
      reversal is DIFFERENT: the original is a legitimate posting, so BOTH journals stay status=posted, linked by
      reverses_journal_id, use a distinct source (e.g. accrual / accrual-reversal), and the reversal posts on day-1
      of the next OPEN period. Marking the original reversed would drop a valid accrual from any status=posted
      report and land the offset in the wrong period (wrong-period P&L for both months).
  - id: deploy
    title: The gl-posting-engine (image mode, pure SQL, SerializationFailure retry)
    when: always
    tools: [auth_service-account_create, auth_service-account_grant-role, auth_service-account_roles, ignite_app_deploy]
    detail: >-
      Package the balance-gate + idempotent post + reversal as an image-mode Ignite app (Dockerfile Kaniko-built
      on deploy; GET /healthz + POST /post) that writes over the pg wire (psycopg) — no k3.dodil.io HTTP API, no
      Models. Every write is INSERT … ON CONFLICT DO UPDATE wrapped in a SerializationFailure/DeadlockDetected
      retry loop ({{concurrency_retries}} attempts): the tables engine is serializable, so concurrent posters
      retry rather than corrupt a balance. 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 is the cli-…
      serviceAccountId (the uuid fails client_credentials with invalid_client). SHOWN-AS-CODE here — the retry
      loop + Dockerfile are in the post; a dedicated live-deploy stress test (the gl-posting-engine as the
      idempotency-under-load proof) runs in the suite phase, reusing itsm-sla-monitor's validated image-mode shape.

# The Q&A an agent runs with the user (was `## Customize`). Each answer's effect = the best-practice encoding.
customize:
  - param: functional_currency
    ask: "What is the functional (reporting) currency the ledger is kept in?"
    effects:
      USD: "every ledger row this skill writes carries this ISO currency; it must match gl/core's functional_currency. Set once in gl/core and every downstream skill inherits it."
  - param: audit_log
    ask: "Write an append-only journal_audit event (posted|reversed) on every posting?"
    effects:
      "true":  "each post/reverse appends one journal_audit row (uuid PK, plain INSERT — a fresh uuid never collides); the immutable trail a controller reads. To mint audit_id server-side use BARE UUID() (or GENERATE_UUID()): INSERT INTO journal_audit (audit_id, …) VALUES (UUID(), …) — gen_random_uuid() is NOT available and a cast on the generator (UUID()::text) raises 42601; call the bare function. ## Test asserts 3 events after post+reverse (2:posted, 2:reversed, 3:posted)."
      "false": "no audit trail — the journals.status transitions are the only history. Keep it on for a real ledger; an auditor expects the event log."
  - param: concurrency_retries
    ask: "How many times should the posting engine retry a SerializationFailure before giving up?"
    effects:
      default: >-
        the tables engine is serializable, so two posters hitting the same account can collide; the engine
        wraps each write in a retry loop that re-runs on SerializationFailure/DeadlockDetected. Default 4
        attempts with backoff; sets the retry bound in gl-posting-engine. Idempotent ON CONFLICT writes make
        every retry safe (a re-run posts once).

# What proves it (was `## Test`) — assertions the CI replay runs on the default branch.
# Live-validated 2026-09-03, org IHDIASH, bucket glb-je-7k2m (torn down). Real returned values inline.
tests:
  - "balance gate rejects unbalanced: journal 2 staged debit 25000.00 / credit 24000.00 -> imbalance 1000.00 (rejected, never posts)"
  - "balance gate accepts balanced: corrected to 25000.00 / 25000.00 -> imbalance 0.00 (may post)"
  - "post moves the ledger: after posting journal 2, Cash 500000.10 -> 475000.10, Salaries +25000.00; ledger_rows 8; trial balance SUM(ledger.balance) = 0.00"
  - "no double-post: a bare re-INSERT of line (2,1) raises SQLSTATE 23505; re-posting both lines ON CONFLICT + re-materializing leaves journal_lines=2, ledger_rows=8, Cash 475000.10, Salaries 25000.00, trial balance 0.00"
  - "reversal nets to zero: journal 3 (reverses_journal_id=2, debit/credit swapped) -> Cash back to 500000.10, Salaries 0.00, net(journals 2+3)=0.00, trial balance 0.00; journal 2 status=reversed"
  - "audit trail: journal_audit has 3 append-only events -> (2,posted), (2,reversed), (3,posted)"
  - "data connect {{bucket}} -> prints pg.uk-lon-1.dodil.io:5432/{{bucket}} + bolt + grpc"

tested_branches:
  - { functional_currency: USD, audit_log: true, concurrency_retries: 4, tested_at: 2026-09-03 }   # anchor — gate + idempotent post + reversal + audit, all live on glb-je-7k2m
  - { audit_log: false, tested_at: 2026-09-03 }   # derived — same posting path, journal_audit writes skipped

# Ship-it contract for the posting engine.
# NOTE (2026-09-03): VALIDATED LIVE END-TO-END, including a concurrency STRESS TEST — no longer shown-as-code.
# The data logic (balance gate, idempotent ON CONFLICT post, reversal, audit) was proven query-by-query, and
# gl-posting-engine was DEPLOYED LIVE (app ihdiash/gl-posting-engine, image mode/Lane B Kaniko, medium tier,
# pinned warm --reserved 1 --max-replicas 1, --allow-unauthenticated; GET /healthz → 200 in 0.57s) and hammered
# with true concurrent HTTP: 24-way SAME-journal → exactly-once (journal_lines=2 not 48, ledger moved once,
# TB 0.00); 60-way DISTINCT → 60/60 posted, 37 SerializationFailure/backpressure retries absorbed, TB 0.00;
# 10 unbalanced under load → all 400-rejected, 0 leaked; durable re-read → 0 ledger-vs-source mismatches over
# 112 journals. The pg wire is finance-ready under load. SA role set verified EXACTLY k3.editor +
# ignite.app-developer (NO ignite.model-user). DODIL_SERVICE_ACCOUNT_ID = the cli-… serviceAccountId (NOT uuid).
# THREE load-bearing engine facts (verified live — build the handler this way):
#  1. `DO UPDATE SET` accepts ONLY `EXCLUDED.<col>` refs — a literal (`SET status='posted'`) is rejected
#     FeatureNotSupported; put the constant in VALUES and reference EXCLUDED.status.
#  2. NO read-your-writes WITHIN an open txn (write-log staged to commit) — re-derive the ledger in a SECOND
#     committed txn after the lines commit, idempotently (SUM(debit−credit), never +=), so N concurrent
#     re-posts land the amount once.
#  3. The tables reader has a hard 16-concurrent-read serve cap (ConfigurationLimitExceeded) + transient
#     object-store blips under a wide storm (sometimes misclassified as SyntaxError) — absorb both with a
#     serializable retry loop matched by message markers PLUS a client admission semaphore <16 (DB_CONCURRENCY=10)
#     on the single pinned replica. That combo took the 60-way storm from lossy to 60/60 clean, TB 0.00.
deploy:
  app: gl-posting-engine
  mode: image            # Dockerfile + --dockerfile-path (NOT --runtime python / compile mode)
  entrypoint: "http server (0.0.0.0:$PORT, /healthz + POST /post)"
  dockerfile: posting-engine/Dockerfile
  status: validated-live  # deployed + concurrency-stress-tested live 2026-09-03 (app ihdiash/gl-posting-engine)
  command: >-
    dodil ignite app deploy gl-posting-engine --code ./posting-engine --dockerfile-path Dockerfile
    --port 8080 --health-path /healthz --allow-unauthenticated
    --env BUCKET=$BUCKET --env FUNCTIONAL_CURRENCY=USD --env CONCURRENCY_RETRIES=4
    --env DODIL_SERVICE_ACCOUNT_ID=<cli-… serviceAccountId> --env DODIL_SERVICE_ACCOUNT_SECRET=$SA_SECRET
  service_account:
    roles: [k3.editor, ignite.app-developer]   # pure SQL — NO ignite.model-user
    note: >-
      DODIL_SERVICE_ACCOUNT_ID = the cli-… serviceAccountId printed by `auth service-account create`,
      NOT the internal uuid (the uuid fails client_credentials with invalid_client).
  data_plane: "pg-wire pg.uk-lon-1.dodil.io:5432 (psycopg; idempotent write = INSERT … ON CONFLICT (<pk>) DO UPDATE, a bare re-INSERT of a committed PK raises duplicate-key 23505 — verified live) — no k3.dodil.io HTTP API"
  concurrency: "serializable tables engine — every posting write wrapped in a SerializationFailure/DeadlockDetected retry loop (CONCURRENCY_RETRIES attempts, backoff); idempotent ON CONFLICT writes make each retry safe"
  scheduling: "no server-side scheduler — POST /post is request-invoked (from the CLI, an app, or a queue consumer)"
  env: [DODIL_SERVICE_ACCOUNT_ID, DODIL_SERVICE_ACCOUNT_SECRET, BUCKET, FUNCTIONAL_CURRENCY, CONCURRENCY_RETRIES]
  models: []             # GL journal entry is pure SQL — no Models call
