upload current sources

This commit is contained in:
2026-09-16 01:47:28 -07:00
commit f8b45ea4a5
109 changed files with 26012 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
# ADR-001 — CAP-first rebuild; Express predecessor demoted to reference
Date: 2026-09-04 · Status: Accepted — **amended by ADR-002 (2026-09-04)**: the
rebuild target is now on-stack RAP + Fiori Elements in the tenant, with a
side-by-side connectivity worker; CAP exits the architecture. This ADR's core
rationale — the SAP toolchain must engage, and the Express shape is not the
pattern — stands unchanged.
## Context
The working predecessor (`~/projects/sap-apps/arena-plm`) is Express.js + axios
with hand-rolled SAP patterns. It functions, but it sits outside the SAP-native
toolchain: the CAP, Fiori, and UI5 MCP servers and skills had nothing to attach
to, so development guidance never engaged, and the implementation drifted from
SAP best practice — hand-managed CSRF tokens, an approval store in droplet-local
SQLite that vanishes on restart, JWT validation with no scope checks. The estate
is explore-phase; there is no production cutover to protect, so preserving the
legacy shape has no value beyond reference.
## Decision
I rebuild arena-plm as a CAP (Node.js) application: the approval domain modeled
in CDS, all S/4HANA calls through CAP remote services / SAP Cloud SDK via the
`mc-s4hana` destination, and Fiori Elements evaluated before any custom UI5.
The Express app is reference-only — consulted for behavior and tombstones,
never extended.
## Consequences
- The SAP toolchain becomes load-bearing: `cap-mcp-server` is consulted on
every model and service change, which is the corrective for the
tooling-never-engaged failure mode.
- Auth, CSRF, and destination handling move onto framework-managed code paths,
eliminating the predecessor's hand-rolled plumbing.
- The approval workflow domain must be re-modeled in CDS; behavior parity is
checked against the reference app, never assumed from it.
- Whether the sync-pipeline half stays inside CAP or becomes a separate
hardened worker is deliberately deferred (PRD OQ-4).
+85
View File
@@ -0,0 +1,85 @@
# ADR-002 — On-stack first: RAP approval domain + Fiori app in the tenant; side-by-side reduced to a connectivity worker
Date: 2026-09-04 · Status: Accepted (amends ADR-001)
## Context
The decisive test came from outside: the showcased UI apps present the data
well, but they are invisible from inside S/4HANA — not in the Fiori launchpad,
not discoverable in the Custom CDS Views app, reachable only via standalone
Cloud Foundry URLs behind an ad-hoc route structure with weak access control.
For an S/4HANA user, an application that cannot be found in the tenant does not
exist.
ADR-001 correctly rejected the Express shape but still assumed a side-by-side
BTP application (CAP) as the rebuild target. That assumption fails the same
test: a CAP app on BTP is still a standalone URL outside the tenant.
S/4HANA Cloud Public Edition offers two in-tenant extensibility tiers, both
verified against SAP documentation (2026-09-04, via sap-docs MCP):
- **Developer extensibility**: upgrade-stable ABAP Cloud development directly
on the stack (ADT against the development tenant) — CDS entities, RAP
business objects with behavior, custom Fiori apps deployed into the tenant,
authorization via IAM app → business catalog → business role.
- **Key-user extensibility**: Custom Business Objects, Custom CDS Views (on
released C1 view entities), Custom Analytical Queries, and Business
Configuration maintenance via the Custom Business Configurations app.
The estate's standing rule already says extensibility-first: check what the
tenant can host before building side-by-side.
## Decision
I move the application into the tenant and shrink the side-by-side footprint to
what genuinely cannot run there:
**On-stack (developer extensibility, ABAP Cloud / RAP):**
- A custom RAP business object for the Arena change approval domain — staging
data, approval state machine (pending → approved/rejected), audit fields —
persisted in the tenant.
- A Fiori Elements List Report / Object Page on that BO, deployed to the
tenant's Fiori launchpad; approve/reject as RAP actions.
- IAM app, business catalog, and business roles as the only access control —
no user-facing custom URLs anywhere.
- Custom CDS views over the approval BO (joinable with released views such as
I_Product) so the data is discoverable in the Custom CDS Views app and usable
in analytical queries — the discoverability test that triggered this ADR.
- Business mappings evaluated as Business Configuration maintenance objects
(Custom Business Configurations app) instead of JSON files in git.
**Side-by-side (one hardened TypeScript worker, no UI, no user URLs):**
- Arena connectivity only: webhook receipt, delta polling, session management
(25-min TTL, login race), file streaming.
- Initially also the sync executor: on approval, it calls the released OData
APIs (Product/BOM/ChangeMaster/Attachment) exactly as the predecessor did —
every KNOWLEDGE.md tombstone stays valid unchanged.
- It exchanges data with the tenant through the approval BO's service binding
exposed via a custom communication scenario/arrangement with a technical
user.
CAP exits the architecture: with the domain, UI, and persistence on-stack, the
remaining side-by-side piece is a pipeline, and per the two-archetype standard
a pipeline is a worker, not a CAP service. This resolves ADR-001's deferred
question and PRD OQ-1 through OQ-4 in one move.
## Consequences
- The Fiori launchpad becomes the application's only front door; the shared
BTP gateway, XSUAA role juggling, and standalone routes disappear from this
package entirely.
- Persistence and audit live in the tenant — the predecessor's
lost-on-restart approval queue defect is solved structurally, not patched.
- The toolchain shifts: ABAP Cloud + RAP + Fiori Elements, so the MCP loadout
moves to sap-docs / abap-docs / sap-released-objects / fiori / ui5, and
cap-mcp-server leaves the loadout.
- Verification items before build (POC gates):
- V-1: confirm developer-extensibility access on my429480 (development
tenant reachable via ADT, developer business role assigned).
- V-2: confirm a custom communication scenario can expose the approval BO's
OData service to the worker's technical user.
- V-3: evaluate on-stack Application Jobs vs the worker for Arena delta
polling; and Business Configuration objects vs config files (ARN-004).
- Fallback recorded: if developer extensibility proves unavailable on this
tenant, the key-user tier (Custom Business Object with generated UI) is the
degraded-but-still-in-tenant variant; side-by-side is no longer the fallback.
@@ -0,0 +1,94 @@
# ADR-003 — Ground-truth enforcement: hooks, not advice
Date: 2026-09-04 · Status: Accepted
## Context
The old estate proved that advisory guidance fails: an MCP routing table, skills,
and operating rules existed for months, yet the servers and skills were never
consulted during development — even on the CAP packages where they obviously
applied. Causes: tool-selection dilution under a saturated loadout, zero
enforcement (prose loses to momentum), friction (cold starts, deferred tool
loading), and model overconfidence in trained SAP knowledge. The requirement for
this repo: consultation must be structural — the agent must forget what it
thinks it knows, consult the MCP servers and context7 before work starts, and be
able to cite sources on demand.
## Decision
Four mechanisms in `.claude/settings.json` + `.claude/hooks/` (project scope,
part of the founding kit for every future package):
1. **SessionStart injection** — every session opens with the grounding charter:
training knowledge is not a citable source; the five MCP servers + context7
are; if tools are deferred, loading them is the first action.
2. **Per-prompt reflex (UserPromptSubmit)** — a SAP-scoped analogue of the
machine-global TOOL-REFLEX hook: never classify whether a prompt "needs" a
consult; the classification step is the known failure.
3. **Consultation gate (PreToolUse deny)** — every successful `mcp__*`,
WebSearch, or WebFetch call stamps `.claude/.last-mcp-consult`; a Write/Edit
on a SAP artifact (`.cds`, `.abap`, `.bdef`, annotations, views,
`manifest.json`, `srv/`, `app/`, `worker/`) is **denied** unless a
consultation happened within 30 minutes, with the remedy stated in the denial
reason. Governance `.md`, `docs/`, and `.claude/` paths are exempt so
documentation never jams.
4. **Citation duty** — every SAP behavioral claim recorded in PRD/ADR/KNOWLEDGE
carries `[source: <server> <doc-id/URL>, verified YYYY-MM-DD]` (CLAUDE.md
rule 6); a PostToolUse reminder fires on governance-doc edits. Uncited =
hypothesis.
## On "load tools automatically at session start"
**Corrected 2026-09-04** after reading the 2026-09-03 "Investigate performance
lag issue" session transcript: my original claim ("no setting exists to preload
deferred tools") was true of settings *keys* but wrong in practice. The
`ENABLE_TOOL_SEARCH=false` environment variable disables deferral entirely
(verified against the binary in that session). It must be set before process
bootstrap — the settings.json `env` block loads too late, per that session's
finding — so it lives in the `claude()` wrapper in `~/.bashrc`, where it is
**already active machine-wide**, including for sessions started in this repo.
History: preloading was first measured at ~98k tokens/turn at the old ~950-tool
surface and rejected in favor of the TOOL-REFLEX hook; once plugin localization
cut the surface to ~40 tools, the same session adopted preloading via the
wrapper. This repo's slim 5-server loadout keeps the preload cost small.
Mechanism 1 (SessionStart load-first order) remains as the fallback for launch
paths that bypass the wrapper (e.g. IDE-spawned sessions).
## Consequences
- Consultation stops being a virtue and becomes a precondition; skipping it now
produces a denied edit instead of silent drift.
- The 30-minute freshness window is a tunable; if it proves too tight or too
loose in practice, adjust it in `sap-consult-gate.sh` and note it here.
- The gate measures that *a* consultation happened, not that the *right* one
did — citation duty and review cover relevance.
- Hooks activate on the first session started inside this repo (project hooks
are read at session start).
## Amendment 2026-09-14 — gate hardened after code review
The 2026-09-04 gate was advisory in practice: the `Write|Edit` matcher missed
Bash writes (sed -i, heredocs, tee, cp/mv, interpreter one-liners), MultiEdit,
NotebookEdit and filesystem-MCP edits; the `mcp__.*` PostToolUse matcher let
any MCP call (memory, chrome, mcp-manage) refresh the 30-minute marker; hook
commands hard-coded this checkout's absolute path; and a missing `jq` produced
a silent allow. I changed the mechanism, not the prose:
- PreToolUse gate matches `Write|Edit|MultiEdit|NotebookEdit|Bash|
mcp__filesystem__write_file|mcp__filesystem__edit_file|mcp__filesystem__move_file`;
for Bash the script gates only commands that combine a write indicator with
a gated path (documented heuristic, known blind spots: variable-built paths,
`cd`-relative paths, writes by invoked scripts).
- The consult marker is refreshed only by the grounding servers (sap-docs,
abap-docs, sap-released-objects, the Fiori/UI5 servers, context7, CAP) and
WebSearch/WebFetch — anchored regex in `settings.json`.
- Hook commands resolve via `"${CLAUDE_PROJECT_DIR}"`; scripts prefer that
variable over `$0` [source: https://code.claude.com/docs/en/hooks —
"`${CLAUDE_PROJECT_DIR}`: the project root where the session started",
verified 2026-09-14].
- Missing `jq` or an unresolvable marker now denies (fail closed); `jq` is a
hard dependency of any session in this repo.
- `*/worker/*` left the gated set: the TypeScript worker is governed by
context7 and the OpenAPI contract, not the SAP gate.
- `.claude/hooks/tests/run.sh` pipe-tests both scripts (37 cases) against an
override marker so the real marker is never touched.
+64
View File
@@ -0,0 +1,64 @@
# Specialist Agent Roster — founding-kit design
Compiled 2026-09-04 from a 4-agent verified research sweep (3 researchers +
1 adversarial verifier; 37 findings, 22 confirmed, 14 downgraded/refuted and
discarded). Every design choice below cites a CONFIRMED source. This is the
"dedicated agents" layer of the founding kit; the files live in
`.claude/agents/`.
## Design principles (adopted from confirmed sources)
1. **Tool restrictions enforce role — prompts merely request it.** A reviewer
without Write cannot introduce bugs; a test agent without deploy tools
cannot ship. [source: https://www.pubnub.com/blog/best-practices-for-claude-code-sub-agents/ + https://github.com/VoltAgent/awesome-claude-code-subagents, verified 2026-09-04]
2. **Every agent carries the MCP routing table for its artifacts** — the
pattern SAP itself demonstrates in its official agentic CAP reference app
(AGENTS.md maps each artifact type to its grounding server). [source: https://github.com/SAP-samples/cap-agentic-engineered, verified 2026-09-04]
3. **Model per role:** implementation agents inherit the session model;
the test agent runs on Haiku (cheap, mechanical work). [source: https://github.com/lst97/claude-code-sub-agents test-automator pattern — repo verified WEAK on scale claims but the model-tiering pattern confirmed on page, 2026-09-04]
4. **Reviewer filters false positives explicitly** (excluded classes declared
up front), per Anthropic's own security-review agent. [source: https://github.com/anthropics/claude-code-security-review, verified 2026-09-04]
5. All agents inherit this repo's enforcement layer (ADR-003 gate + citation
duty) automatically — hooks are project-scoped, so no per-agent wiring.
## The roster (5 — the cap)
| Agent | Scope | Tools (restriction is the contract) | Model |
|---|---|---|---|
| `rap-developer` | On-stack ABAP Cloud: RAP BO, CDS, behavior, BDEF, IAM artifacts | Edit/Write/Read/Grep/Glob/Bash + abap-docs, sap-docs, sap-released-objects | inherit |
| `fiori-developer` | Fiori Elements app, annotations, manifest, launchpad content | Edit/Write/Read/Grep/Glob/Bash + fiori-mcp-server (3-step workflow), ui5-mcp-server | inherit |
| `worker-developer` | TS connectivity worker: Arena API, Cloud SDK OData writes | Edit/Write/Read/Grep/Glob/Bash + context7 | inherit |
| `sap-code-reviewer` | Clean-core, security, contract conformance review | **Read/Grep/Glob + doc MCPs only — no Write, no Bash** | inherit |
| `test-engineer` | Unit/integration tests for worker + service layers | Edit/Write/Read/Grep/Glob/Bash (test commands only by instruction) | haiku |
## MCP additions (recommended, gated — not installed)
The on-stack RAP work needs an **ADT MCP server** (live syntax check, ATC,
activation, repository objects). Verified options, in preference order per the
prefer-standard rule:
1. **SAP's official ABAP MCP path** — SAP's agentic-ABAP direction (VS Code
ADT + zero-config MCP) is announced and rolling out; `sapse.adt-vscode` is
already installed on this machine. Evaluate first at V-1.
[source: https://community.sap.com/t5/technology-blog-posts-by-sap/entering-the-new-era-of-agentic-ai-for-abap-development/ba-p/14394643 + community post ba-p/14408186, verified 2026-09-04]
2. **ARC-1** (`arc-mcp/arc-1`) — enterprise ABAP/ADT MCP, maintained.
[source: https://github.com/arc-mcp/arc-1, verified 2026-09-04]
3. **`yzonur/sap-adt-mcp`** — 27 ADT tools incl. quality gates, read-only
flag, audit logging. [source: https://github.com/yzonur/sap-adt-mcp, verified 2026-09-04]
Optional later: SAP's official **BTP Administration MCP** for the devops side.
[source: https://community.sap.com/t5/technology-blog-posts-by-sap/public-release-of-the-mcp-server-for-sap-btp-administration/ba-p/14471692, verified 2026-09-04]
Registry for future discovery: https://github.com/marianfoo/sap-ai-mcp-servers
(verified 2026-09-04). Note: `marianfoo/mcp-sap-notes` is archived (June 2026),
superseded by `marianfoo/sap-mcp-servers` — do not adopt the archived one.
## Discarded by verification (recorded so they are not re-researched)
- `MariusAlbrecht/mcp-adt` — does not exist (404; the real ADT bridge is the
separate "vs-punk" project).
- SAP GUI automation MCP — cited listing 404s; GUI automation irrelevant to
Public Cloud anyway.
- `rap-skills`, `claude-abap-skills` — real but 1–14 commits; mine their
*scenario decomposition ideas* (generator/CDS/behavior/testing/troubleshoot),
do not depend on the repos.
+731
View File
@@ -0,0 +1,731 @@
{
"openapi": "3.1.0",
"info": {
"title": "arena-plm connectivity worker API",
"version": "0.2.1",
"x-sap-shortText": "Inbound machine-to-machine surface of the arena-plm connectivity worker: Arena PLM webhook intake, health probe, and sync-run control.",
"x-sap-stateInfo": {
"state": "beta"
},
"description": "POC-stage contract for the inbound HTTP surface of the arena-plm connectivity worker (a machine-to-machine TypeScript worker on BTP Cloud Foundry; no UI, no user-facing URLs). This document governs the implementation (API-first).\n\nScope: inbound endpoints only. The worker's outbound legs — the Arena PLM REST API (`api.arenasolutions.com/v1`) and the SAP S/4HANA released OData APIs — are governed by their providers' contracts and are out of scope for this document.\n\nStability: this is a POC-stage contract. The Arena webhook payload (ArenaWebhookEvent) is provisional pending the Arena POC, and the authentication scheme is a placeholder pending PRD open question OQ-6."
},
"servers": [
{
"url": "/",
"description": "CF route, decided at deployment (OQ-6)"
}
],
"tags": [
{
"name": "system",
"description": "Liveness/readiness for Cloud Foundry health checks."
},
{
"name": "webhooks",
"description": "Inbound event intake from Arena PLM (ARN-005)."
},
{
"name": "admin",
"description": "Machine-to-machine administrative operations (sync run control)."
}
],
"paths": {
"/health": {
"get": {
"operationId": "getHealth",
"summary": "Liveness/readiness probe",
"description": "Used by Cloud Foundry health checks. Unauthenticated by design; exposes no business data. `arenaSession.state` reflects the worker's serialized Arena session lifecycle (Arena sessions have a ~25-minute TTL and logins must be serialized; see KNOWLEDGE.md). `poller` reports the delta poller's most recent tick; it is absent until the poller has ticked at least once.",
"tags": [
"system"
],
"security": [],
"responses": {
"200": {
"description": "Worker is up (HTTP 200 in both states — this is a liveness probe, not a readiness gate). `status` is `degraded` when the most recent delta-poll tick failed (an Arena error, or the ingest buffer being full at poll time — the tick is failed without advancing the cursor so the window is re-covered) or the most recent Arena login failed; it returns to `ok` on the next successful tick/login.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HealthStatus"
},
"examples": {
"ok": {
"summary": "Fully operational",
"value": {
"status": "ok",
"version": "0.1.0",
"uptimeSeconds": 5231.7,
"arenaSession": {
"state": "active"
},
"poller": {
"lastRunAt": "2026-09-14T08:15:00Z",
"lastSuccessAt": "2026-09-14T08:15:00Z"
}
}
},
"degraded": {
"summary": "Last poll tick failed",
"value": {
"status": "degraded",
"version": "0.1.0",
"uptimeSeconds": 5531.2,
"arenaSession": {
"state": "expired"
},
"poller": {
"lastRunAt": "2026-09-14T08:20:00Z",
"lastSuccessAt": "2026-09-14T08:15:00Z",
"lastError": "Upstream Arena PLM request failed (HTTP 503)."
}
}
}
}
}
}
},
"default": {
"$ref": "#/components/responses/Default"
}
}
}
},
"/webhooks/arena": {
"post": {
"operationId": "receiveArenaWebhook",
"summary": "Arena PLM event intake",
"description": "Receives Arena PLM webhook events (ARN-005). The worker validates only that the body is a JSON object, then queues the event for asynchronous processing; acceptance (202) does not imply the event was processed or will result in a staged change. The ingest queue is a bounded transient buffer (`WEBHOOK_QUEUE_MAX_SIZE`, default 10000; never durable worker-local storage — ARN-003); when it is full the event is NOT accepted and the sender must retry (503 with `Retry-After`). The request body schema (ArenaWebhookEvent) is PROVISIONAL pending the Arena POC and will be tightened then.",
"tags": [
"webhooks"
],
"security": [
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ArenaWebhookEvent"
},
"example": {
"eventType": "ITEM_MODIFIED",
"itemGuid": "K3C9D0E1F2",
"itemNumber": "900-00042",
"occurredAt": "2026-09-10T08:15:30Z"
}
}
}
},
"responses": {
"202": {
"description": "Event accepted and queued for asynchronous processing.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookAccepted"
},
"example": {
"received": true
}
}
}
},
"400": {
"description": "Malformed request (e.g. body is not a JSON object).",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
},
"example": {
"title": "Bad Request",
"status": 400,
"detail": "Request body must be a JSON object."
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"413": {
"$ref": "#/components/responses/PayloadTooLarge"
},
"415": {
"$ref": "#/components/responses/UnsupportedMediaType"
},
"503": {
"description": "Ingest queue is full (bounded transient buffer at `WEBHOOK_QUEUE_MAX_SIZE`). The event was NOT queued; the sender should retry after the `Retry-After` delay. Backpressure is preferred over unbounded memory growth or spilling to worker-local storage (ARN-003).",
"headers": {
"Retry-After": {
"description": "Seconds the sender should wait before retrying (RFC 9110 §10.2.3, delay-seconds form). The worker sends 30.",
"schema": {
"type": "integer",
"minimum": 1
},
"example": 30
}
},
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
},
"example": {
"title": "Service Unavailable",
"status": 503,
"detail": "Ingest queue is full; retry later."
}
}
}
},
"default": {
"$ref": "#/components/responses/Default"
}
}
}
},
"/admin/sync-runs": {
"post": {
"operationId": "startSyncRun",
"summary": "Start a sync run",
"description": "Starts a sync run (full or delta). When `objects` is omitted, the run uses the worker's configured chain order (default PRODUCT → CHANGE_MASTER → BOM → DOCUMENTS; may be a subset via SYNC_OBJECT_ORDER) — requirement ARN-001: config-driven sync chain order. When `objects` is provided, it selects and orders the object types for this run.",
"tags": [
"admin"
],
"security": [
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SyncRunRequest"
},
"example": {
"scope": "full"
}
}
}
},
"responses": {
"201": {
"description": "Sync run created. Execution is asynchronous; poll `GET /admin/sync-runs/{runId}` for progress. All entries in `objects` start as `pending` with no `processed` counter (it appears once the object has started).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SyncRun"
},
"example": {
"runId": "7b1e9a4c-2f6d-4c3a-9e8b-5d0f1a2b3c4d",
"scope": "full",
"state": "queued",
"startedAt": "2026-09-10T08:20:00Z",
"objects": [
{
"objectType": "PRODUCT",
"state": "pending"
},
{
"objectType": "CHANGE_MASTER",
"state": "pending"
},
{
"objectType": "BOM",
"state": "pending"
},
{
"objectType": "DOCUMENTS",
"state": "pending"
}
]
}
}
}
},
"400": {
"description": "Malformed request: invalid `scope` value, `objects` containing an unknown or duplicate object type, a property of the wrong type (no coercion — `{\"scope\": [\"full\"]}` or `{\"objects\": \"BOM\"}` are rejected), or any property not defined by SyncRunRequest (strict rejection, `additionalProperties: false`).",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
},
"example": {
"title": "Bad Request",
"status": 400,
"detail": "Invalid value 'incremental' for 'scope'; expected 'full' or 'delta'."
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"413": {
"$ref": "#/components/responses/PayloadTooLarge"
},
"415": {
"$ref": "#/components/responses/UnsupportedMediaType"
},
"default": {
"$ref": "#/components/responses/Default"
}
}
}
},
"/admin/sync-runs/{runId}": {
"get": {
"operationId": "getSyncRun",
"summary": "Get a sync run by ID",
"description": "Returns the current state of a sync run, including per-object progress in execution order.",
"tags": [
"admin"
],
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"name": "runId",
"in": "path",
"required": true,
"description": "Identifier of the sync run, as returned by `POST /admin/sync-runs`.",
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "The sync run.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SyncRun"
},
"example": {
"runId": "7b1e9a4c-2f6d-4c3a-9e8b-5d0f1a2b3c4d",
"scope": "full",
"state": "failed",
"startedAt": "2026-09-10T08:20:00Z",
"finishedAt": "2026-09-10T08:34:12Z",
"error": "BOM: Upstream Arena PLM request failed (HTTP 503).",
"objects": [
{
"objectType": "PRODUCT",
"state": "completed",
"processed": 15
},
{
"objectType": "CHANGE_MASTER",
"state": "completed",
"processed": 4
},
{
"objectType": "BOM",
"state": "failed",
"processed": 9,
"error": "Upstream Arena PLM request failed (HTTP 503)."
},
{
"objectType": "DOCUMENTS",
"state": "pending"
}
]
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"404": {
"description": "No sync run exists with the given `runId`.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
},
"example": {
"title": "Not Found",
"status": 404,
"detail": "Sync run 7b1e9a4c-2f6d-4c3a-9e8b-5d0f1a2b3c4d not found."
}
}
}
},
"default": {
"$ref": "#/components/responses/Default"
}
}
}
}
},
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"description": "PLACEHOLDER. The final authentication scheme for webhook and admin access is an OPEN decision (PRD OQ-6: webhook receiver hosting and auth for the worker on CF). This scheme stands in for whatever is decided (e.g. XSUAA-issued JWT, static bearer token) and this contract will be revised when OQ-6 is closed. Only `/health` is unauthenticated."
}
},
"responses": {
"Unauthorized": {
"description": "Missing or invalid bearer credentials.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
},
"example": {
"title": "Unauthorized",
"status": 401,
"detail": "Missing or invalid bearer token."
}
}
}
},
"BadGateway": {
"description": "An upstream Arena PLM request failed (network error, timeout, or non-success HTTP status from `api.arenasolutions.com`). RESERVED mapping: every operation that invokes Arena synchronously responds with this instead of 500. The upstream response body is never forwarded — `detail` is a fixed, generic phrase — so Arena error payloads (which may echo request data) cannot leak through the worker. No operation in this version calls Arena synchronously (sync runs and polls are asynchronous); the mapping is defined here so it is contract-governed before the first such operation lands.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
},
"example": {
"title": "Bad Gateway",
"status": 502,
"detail": "Upstream Arena PLM request failed."
}
}
}
},
"Default": {
"description": "Any status code not listed explicitly for the operation. Every response the worker emits is governed: the body is always a `Problem` (RFC 9457, `application/problem+json`). Cases: an unmatched route or method (404, see `x-unmatched-routes`); a status raised by the HTTP framework before the handler runs (e.g. 400 for a `Content-Length` mismatch or an empty `application/json` body); an unhandled error (500). 5xx bodies never carry a `detail` derived from an error message — `title` mirrors the HTTP status phrase and nothing else is disclosed.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
},
"example": {
"title": "Internal Server Error",
"status": 500
}
}
}
},
"UnsupportedMediaType": {
"description": "The request `Content-Type` is not `application/json`. Only `application/json` is parsed; every other media type (including `text/plain`, whose built-in parser is removed) and a missing `Content-Type` on a request with a body is rejected before the handler runs. [source: context7 /fastify/fastify docs/Reference/Errors.md FST_ERR_CTP_INVALID_MEDIA_TYPE; docs/Reference/ContentTypeParser.md removeContentTypeParser, verified 2026-09-14]",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
},
"example": {
"title": "Unsupported Media Type",
"status": 415,
"detail": "Unsupported Media Type"
}
}
}
},
"PayloadTooLarge": {
"description": "The request body exceeds the worker's body limit (framework default 1 MiB = 1048576 bytes; the worker does not raise it). The body is not parsed and nothing is queued or created. [source: context7 /fastify/fastify docs/Reference/Server.md `bodyLimit`; docs/Reference/Errors.md FST_ERR_CTP_BODY_TOO_LARGE, verified 2026-09-14]",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
},
"example": {
"title": "Payload Too Large",
"status": 413,
"detail": "Request body is too large"
}
}
}
}
},
"schemas": {
"HealthStatus": {
"type": "object",
"description": "Worker liveness/readiness report.",
"required": [
"status",
"version",
"uptimeSeconds",
"arenaSession"
],
"properties": {
"status": {
"type": "string",
"enum": [
"ok",
"degraded"
],
"description": "`ok` when fully operational; `degraded` when the most recent delta-poll tick failed or the most recent Arena login failed. A tick fails when Arena cannot be reached/answers an error, or when the ingest buffer is full at poll time (before the fetch, or partially while enqueuing) — the tick is failed rather than dropping changes, the poll cursor holds, and the next tick re-covers the window (duplicates on recovery are acceptable: SAP is the ledger, KNOWLEDGE.md 2026-09-04). Derived from the worker's health registry, which the poller and the Arena session manager report into."
},
"version": {
"type": "string",
"description": "Deployed worker version.",
"examples": [
"0.1.0"
]
},
"uptimeSeconds": {
"type": "number",
"minimum": 0,
"description": "Seconds since the worker process started."
},
"arenaSession": {
"type": "object",
"description": "State of the worker's (serialized) Arena PLM API session.",
"required": [
"state"
],
"properties": {
"state": {
"type": "string",
"enum": [
"none",
"active",
"expired",
"refreshing"
],
"description": "`none`: no session has been established since process start; `active`: a cached session exists and is within its configured TTL; `expired`: a cached session exists but is past its TTL (the next Arena call re-logs in lazily); `refreshing`: a login is in flight (serialized behind the promise lock — concurrent callers join it). `refreshing` takes precedence over the other three while a login is in flight."
}
}
},
"poller": {
"type": "object",
"description": "Most recent delta-poll tick. Absent until the poller has ticked at least once. Timestamps are the worker's clock at the start of the tick.",
"properties": {
"lastRunAt": {
"type": "string",
"format": "date-time",
"description": "Start time of the most recent tick (successful or not)."
},
"lastSuccessAt": {
"type": "string",
"format": "date-time",
"description": "Start time of the most recent tick that completed without error. Absent if no tick has succeeded yet."
},
"lastError": {
"type": "string",
"description": "Fixed, non-sensitive phrase classifying the failure of the most recent tick (allowlist keyed on error class / upstream HTTP status, e.g. `Upstream Arena PLM request failed (HTTP 503).`, `Ingest buffer full at poll time.`, `Unclassified worker error; see worker logs.`); absent when the most recent tick succeeded. Never the raw error message — upstream response bodies, credentials, and session tokens cannot appear here; the full error stays in the worker log."
}
}
}
}
},
"ArenaWebhookEvent": {
"type": "object",
"additionalProperties": true,
"description": "PROVISIONAL envelope for Arena PLM webhook events. All fields are optional and unknown properties are accepted (`additionalProperties: true`) because the real Arena webhook payload shape has not yet been verified against the live Arena API. This schema is provisional pending the Arena POC and WILL be tightened (required fields, closed property set, event-type enum) once the POC establishes ground truth. Do not code against fields beyond those named here.",
"properties": {
"eventType": {
"type": "string",
"description": "Arena event type identifier (exact vocabulary to be established by the Arena POC).",
"examples": [
"ITEM_MODIFIED"
]
},
"itemGuid": {
"type": "string",
"description": "Arena internal identifier (GUID) of the affected item, if the event concerns an item."
},
"itemNumber": {
"type": "string",
"description": "Arena item number of the affected item, if the event concerns an item.",
"examples": [
"900-00042"
]
},
"occurredAt": {
"type": "string",
"format": "date-time",
"description": "When the event occurred in Arena, if provided."
}
}
},
"WebhookAccepted": {
"type": "object",
"description": "Acknowledgement that a webhook event was accepted and queued.",
"required": [
"received"
],
"properties": {
"received": {
"type": "boolean",
"const": true
}
}
},
"SyncRunRequest": {
"type": "object",
"description": "Request to start a sync run. STRICT: unknown properties are rejected with 400 (seam ruling 2026-09-10 — on a machine-to-machine admin API a typo'd property such as `object` instead of `objects` must never silently fall back to defaults). Values are validated as sent: no type coercion (`{\"scope\": [\"full\"]}` is 400, not unwrapped to `\"full\"`) and no server-side default injection.",
"additionalProperties": false,
"required": [
"scope"
],
"properties": {
"scope": {
"type": "string",
"enum": [
"full",
"delta"
],
"description": "`full`: initial/complete sync; `delta`: changes since the last successful run."
},
"objects": {
"type": "array",
"description": "Object types to sync, in execution order. Omit to use the worker's configured chain order (default PRODUCT → CHANGE_MASTER → BOM → DOCUMENTS; may be a subset via SYNC_OBJECT_ORDER) — requirement ARN-001. No schema `default` is declared: the server injects nothing (`useDefaults: false`) and the effective order is the worker's configuration, not a contract constant.",
"items": {
"$ref": "#/components/schemas/SyncObjectType"
},
"minItems": 1,
"uniqueItems": true
}
}
},
"SyncObjectType": {
"type": "string",
"enum": [
"PRODUCT",
"CHANGE_MASTER",
"BOM",
"DOCUMENTS"
],
"description": "Object types handled by the sync chain (ARN-001: PRODUCT → CHANGE_MASTER → BOM → DOCUMENTS). The token is `DOCUMENTS` (plural), as in the requirement."
},
"SyncRun": {
"type": "object",
"description": "A sync run and its current state. The run executes its object types sequentially in the order of `objects` (ARN-001) and stops the chain at the first failed object — remaining objects stay `pending`. `finishedAt` is set when the run reaches `completed` or `failed`.",
"required": [
"runId",
"scope",
"state",
"startedAt",
"objects"
],
"properties": {
"runId": {
"type": "string",
"format": "uuid",
"description": "Server-assigned identifier of the run."
},
"scope": {
"type": "string",
"enum": [
"full",
"delta"
]
},
"state": {
"type": "string",
"enum": [
"queued",
"running",
"completed",
"failed"
],
"description": "`completed`: every object in the chain completed. `failed`: the run aborted — the chain stopped at a failed object or a run-level error occurred; see `error` and the per-object entries in `objects`."
},
"startedAt": {
"type": "string",
"format": "date-time",
"description": "When the run was created/accepted."
},
"finishedAt": {
"type": "string",
"format": "date-time",
"description": "When the run reached a terminal state (`completed` or `failed`). Absent while queued or running."
},
"objects": {
"type": "array",
"description": "Per-object progress in execution order for this run (ARN-001). An ordered progress array communicates chain order and stop-on-failure directly, which a keyed counts map cannot. All entries are `pending` at run creation.",
"items": {
"$ref": "#/components/schemas/SyncObjectProgress"
},
"minItems": 1
},
"error": {
"type": "string",
"description": "Run-level abort reason; present when `state` is `failed`. The failed object type followed by the same fixed, non-sensitive phrase recorded in that object's `error` (e.g. `BOM: Upstream Arena PLM request failed (HTTP 503).`), or `<type>: no executor registered`. Never a raw error message."
}
}
},
"SyncObjectProgress": {
"type": "object",
"description": "Progress of one object type within a sync run's sequential execution chain.",
"required": [
"objectType",
"state"
],
"properties": {
"objectType": {
"$ref": "#/components/schemas/SyncObjectType"
},
"state": {
"type": "string",
"enum": [
"pending",
"running",
"completed",
"failed"
],
"description": "All entries are `pending` at run creation; each advances to `running`, then `completed` or `failed`, in chain order. A `failed` entry stops the chain; entries after it remain `pending`."
},
"processed": {
"type": "integer",
"minimum": 0,
"description": "Number of records processed for this object type; present once the object has run (from `running` onwards), absent while `pending`. POC-stage granularity: this will be refined into created/updated/failed counters when the S/4HANA execution leg lands (same tightening wave as ArenaWebhookEvent)."
},
"error": {
"type": "string",
"description": "Fixed, non-sensitive phrase classifying the failure (allowlist keyed on error class / upstream HTTP status — the same mapping as `HealthStatus.poller.lastError`); present when this object's `state` is `failed`. Never the raw error message: upstream response bodies, credentials, and session tokens cannot appear here; the full error stays in the worker log."
}
}
},
"Problem": {
"type": "object",
"description": "Error envelope per RFC 9457 (application/problem+json).",
"properties": {
"type": {
"type": "string",
"format": "uri-reference",
"description": "URI reference identifying the problem type. Defaults to `about:blank`.",
"default": "about:blank"
},
"title": {
"type": "string",
"description": "Short, human-readable summary of the problem type."
},
"status": {
"type": "integer",
"minimum": 100,
"maximum": 599,
"description": "HTTP status code for this occurrence."
},
"detail": {
"type": "string",
"description": "Human-readable explanation specific to this occurrence."
},
"instance": {
"type": "string",
"format": "uri-reference",
"description": "URI reference identifying this specific occurrence."
}
}
}
}
},
"x-unmatched-routes": "Any request whose method+path is not listed under `paths` is answered 404 with a `Problem` body (application/problem+json), including a known path with an unlisted method — the router answers 404, not 405, for a method mismatch (probed against the worker 2026-09-14; Fastify default not-found handling, see `components.responses.Default`). It is the route-level case of every operation's `default` response and cannot be attached to an operation because no operation matches."
}
+176
View File
@@ -0,0 +1,176 @@
# RAP handover — Arena change-approval BO (stage 1, 2026-09-15)
Target: development tenant `my431353` (ADT destination `0M7DEJI`), package `ZARENA_PLM`
(sub-package of `ZLOCAL`, no transport). The ADT MCP Server can create objects and activate
them but cannot write source (PRD V-4), so every file in this folder is a **ready-to-paste
source** for the VS Code ADT editor. Each file header names the target object, its
`abap:` URI and says "replace entire content".
Every SAP behavioral claim in these files carries a `[source: … , verified 2026-09-15]`
tag; the sources are `abap-docs` (ABAP Cloud keyword docs + SAP samples), `sap-docs`
(SAP Help) and `sap-released-objects` (release states).
## What exists in the tenant right now
Generated by `x-ui-service` (all **active**, ATC 0 findings on 12 objects, 2026-09-15):
| Object | Type | Notes |
|---|---|---|
| `ZZCHNGPPROVAL000` | TABL | persistent table (generator naming) |
| `ZZR_CHANGEAPPROVAL000` | DDLS / BDEF / DCLS | root view entity, managed BDEF, access control |
| `ZZBP_R_CHANGEAPPROVAL000` | CLAS | behavior pool of the base BDEF |
| `ZZC_CHANGEAPPROVAL000` | DDLS / BDEF / DDLX / DCLS | UI projection view, projection BDEF, metadata extension, access control |
| `ZZBP_C_CHANGEAPPROVAL000` | CLAS | projection behavior pool — **delete it** (step 9) |
| `ZZUI_ARENA_CHANGE_APPRVL000_O4` | SRVD + SRVB | service definition and OData V4 **UI** binding (same name), **unpublished** |
| `ZZARENA_CHANGE_APPROVAL000` | RONT + NONT | SAP object type / node type |
Created empty via `create_object` (exist, **no source, inactive**):
| Object | Type | Purpose |
|---|---|---|
| `ZD_CHANGEAPPROVAL_REJECTP` | DDLS | abstract entity — `reject` parameter (`RejectReason`) |
| `ZD_CHANGEAPPROVAL_SYNCEDP` | DDLS | abstract entity — `markSynced` parameter (`SapChangeNumber`) |
| `ZD_CHANGEAPPROVAL_FAILEDP` | DDLS | abstract entity — `markFailed` parameter (`ErrorMessage`) |
| `ZC_CHANGEAPPROVAL_A2X` | DDLS + DCLS | A2X projection view (worker) and its access control |
| `ZAPI_ARENA_CHANGE_APPROVAL` | SRVD | Web API service definition (worker) |
Not yet creatable (the MCP validation refuses until the referenced objects are active):
| Object | Type | Blocker seen 2026-09-15 |
|---|---|---|
| `ZC_CHANGEAPPROVAL_A2X` | BDEF (projection) | "The referenced STOB object ZC_CHANGEAPPROVAL_A2X does not exist" |
| `ZAPI_ARENA_CHANGE_APPROVAL_A2X` | SRVB (OData V4 - Web API) | "An active version of the Service Definition ZAPI_ARENA_CHANGE_APPROVAL does not exist" |
## Paste-and-activate sequence
Open each object in the VS Code ADT editor (or ADT Eclipse), replace the whole source with
the file content, save, activate. Activation order matters — dependencies are top-down.
1. `zzchngpproval000.tabl.asddls` → table `ZZCHNGPPROVAL000`. (File name uses the
abap-file-formats extension by analogy with `.ddls.asddls`; the tenant object is the
same regardless.)
2. `zd_changeapproval_rejectp.ddls.asddls`, `zd_changeapproval_syncedp.ddls.asddls`,
`zd_changeapproval_failedp.ddls.asddls` → the three abstract entities.
3. `zzr_changeapproval000.ddls.asddls` → root view entity.
4. `zzr_changeapproval000.bdef.asbdef` → base BDEF.
Watch for a message about the unmapped elements `StatusText`, `StatusCriticality`,
`IsApprovable`, `IsRetriable` (CDS-calculated, deliberately not in the mapping). A
warning is acceptable; an **error** means the fallback in the file header applies.
5. `zzbp_r_changeapproval000.clas.abap` (global class) and
`zzbp_r_changeapproval000.clas.locals_imp.abap` (Local Types tab) → behavior pool.
6. `zzc_changeapproval000.ddls.asddls` and `zc_changeapproval_a2x.ddls.asddls` → both
projection views.
7. `zzc_changeapproval000.bdef.asbdef` → UI projection BDEF.
8. Create the A2X projection BDEF (now possible): ADT *New Behavior Definition* on
`ZC_CHANGEAPPROVAL_A2X`, implementation type *Projection* — or via MCP:
`abap_creation-run_validation` + `create_object` with objectType `BDEF/BDO` and
`{"behaviorDefinitionType":"definition","packageName":"ZARENA_PLM","rootEntity":"ZC_CHANGEAPPROVAL_A2X","name":"ZC_CHANGEAPPROVAL_A2X","description":"Arena change approval: A2X projection behavior","implementationType":"Projection"}`,
transport `""`. Then paste `zc_changeapproval_a2x.bdef.asbdef`, activate.
9. Delete the generated class `ZZBP_C_CHANGEAPPROVAL000` (no longer referenced by the
projection BDEF; a projection needs a behavior pool only for new actions/functions
[source: abap-docs /abap-docs-cloud/ABENBDL_BDEF_PROJECTION_HEADER, verified 2026-09-15]).
10. `zzr_changeapproval000.dcls.asdcls`, then `zzc_changeapproval000.dcls.asdcls` and
`zc_changeapproval_a2x.dcls.asdcls` → access controls.
11. `zzc_changeapproval000.ddlx.asddlx` → metadata extension.
12. `zzui_arena_change_apprvl000_o4.srvd.srvdsrv` and
`zapi_arena_change_approval.srvd.srvdsrv` → service definitions.
13. Create the Web API binding (now possible): ADT *New Service Binding* on
`ZAPI_ARENA_CHANGE_APPROVAL`, binding type *OData V4 - Web API*, name
`ZAPI_ARENA_CHANGE_APPROVAL_A2X` — or via MCP `create_object` with objectType
`SRVB/SVB` and
`{"packageName":"ZARENA_PLM","name":"ZAPI_ARENA_CHANGE_APPROVAL_A2X","description":"Arena change approval: OData V4 Web API binding (worker)","bindingType":"OData V4 - Web API","serviceDefinition":"ZAPI_ARENA_CHANGE_APPROVAL"}`.
Activate. **Do not publish** either binding (publishing is a later brief, together
with the IAM app / communication scenario).
## Activation URIs for `abap_activate_objects` (after pasting)
Pattern learned live 2026-09-15: `abap:/repotree-v1/0M7DEJI/System%20Library/ZLOCAL/ZARENA_PLM/<folder>/<NAME>/<name>.<ext>`
with these folders/extensions (all verified by a successful activation):
| Type | Folder | Extension |
|---|---|---|
| TABL | `Dictionary/Database%20Tables` | `.tabl.json` |
| DDLS | `Core%20Data%20Services/Data%20Definitions` | `.ddls.acds` |
| BDEF | `Core%20Data%20Services/Behavior%20Definitions` | `.bdef.json` (`.bdef.acds`, `.bdef.asbdef`, `.bdef.abap` all fail with "array element 0 is null") |
| DDLX | `Core%20Data%20Services/Metadata%20Extensions` | `.ddlx.acds` |
| DCLS | `Core%20Data%20Services/Access%20Controls` | `.dcls.acds` |
| CLAS | `Source%20Code%20Library/Classes` | `.clas.abap` |
| SRVD | `Business%20Services/Service%20Definitions` | `.srvd.acds` |
| SRVB | `Business%20Services/Service%20Bindings` | `.srvb.json` |
| RONT | `Business%20Services/SAP%20Object%20Types` | `.ront.json` |
| NONT | `Business%20Services/SAP%20Object%20Node%20Types` | `.nont.json` |
In sequence order (`B` = `abap:/repotree-v1/0M7DEJI/System%20Library/ZLOCAL/ZARENA_PLM`):
```
B/Dictionary/Database%20Tables/ZZCHNGPPROVAL000/zzchngpproval000.tabl.json
B/Core%20Data%20Services/Data%20Definitions/ZD_CHANGEAPPROVAL_REJECTP/zd_changeapproval_rejectp.ddls.acds
B/Core%20Data%20Services/Data%20Definitions/ZD_CHANGEAPPROVAL_SYNCEDP/zd_changeapproval_syncedp.ddls.acds
B/Core%20Data%20Services/Data%20Definitions/ZD_CHANGEAPPROVAL_FAILEDP/zd_changeapproval_failedp.ddls.acds
B/Core%20Data%20Services/Data%20Definitions/ZZR_CHANGEAPPROVAL000/zzr_changeapproval000.ddls.acds
B/Core%20Data%20Services/Behavior%20Definitions/ZZR_CHANGEAPPROVAL000/zzr_changeapproval000.bdef.json
B/Source%20Code%20Library/Classes/ZZBP_R_CHANGEAPPROVAL000/zzbp_r_changeapproval000.clas.abap
B/Core%20Data%20Services/Data%20Definitions/ZZC_CHANGEAPPROVAL000/zzc_changeapproval000.ddls.acds
B/Core%20Data%20Services/Data%20Definitions/ZC_CHANGEAPPROVAL_A2X/zc_changeapproval_a2x.ddls.acds
B/Core%20Data%20Services/Behavior%20Definitions/ZZC_CHANGEAPPROVAL000/zzc_changeapproval000.bdef.json
B/Core%20Data%20Services/Behavior%20Definitions/ZC_CHANGEAPPROVAL_A2X/zc_changeapproval_a2x.bdef.json
B/Core%20Data%20Services/Access%20Controls/ZZR_CHANGEAPPROVAL000/zzr_changeapproval000.dcls.acds
B/Core%20Data%20Services/Access%20Controls/ZZC_CHANGEAPPROVAL000/zzc_changeapproval000.dcls.acds
B/Core%20Data%20Services/Access%20Controls/ZC_CHANGEAPPROVAL_A2X/zc_changeapproval_a2x.dcls.acds
B/Core%20Data%20Services/Metadata%20Extensions/ZZC_CHANGEAPPROVAL000/zzc_changeapproval000.ddlx.acds
B/Business%20Services/Service%20Definitions/ZZUI_ARENA_CHANGE_APPRVL000_O4/zzui_arena_change_apprvl000_o4.srvd.acds
B/Business%20Services/Service%20Definitions/ZAPI_ARENA_CHANGE_APPROVAL/zapi_arena_change_approval.srvd.acds
B/Business%20Services/Service%20Bindings/ZZUI_ARENA_CHANGE_APPRVL000_O4/zzui_arena_change_apprvl000_o4.srvb.json
B/Business%20Services/Service%20Bindings/ZAPI_ARENA_CHANGE_APPROVAL_A2X/zapi_arena_change_approval_a2x.srvb.json
```
Then re-run ATC on the package objects (`abap_atc_run`; omit RONT/NONT — the ATC API
answers "No valid data model exists for object type RONT/ROT") and
`abap_business_services-fetch_services` on both bindings.
## Design decisions baked into the files (falsifiable, dated 2026-09-15)
- **Two projections, two service definitions.** The brief asked for both bindings on the
generated service definition *and* for the UI to expose only approve/reject/retrySync
while the worker gets create + mark*. A service definition exposes one projection, and a
projection BDEF exposes exactly what it lists (no automatic inheritance
[source: abap-docs /abap-docs-cloud/ABENBDL_USE_PROJECTION]). Both requirements cannot
hold on one definition, so the A2X path got its own projection view/BDEF/definition and
the Web API binding sits on `ZAPI_ARENA_CHANGE_APPROVAL`. The RAP-2 "read-only from the
UI" requirement wins over the structural wording.
- **`authorization master ( none )`** instead of the generator's `( global )`: ADR-002
makes the IAM app / business catalog the only access control and DCL governs reads;
`( none )` is the documented way to declare "no RAP authorization checks"
[source: abap-docs /abap-docs-cloud/ABENBDL_AUTHORIZATION]. Flip to `( global )` plus a
`FOR GLOBAL AUTHORIZATION` handler when a custom authorization object exists.
- **Derived fields are computed, not persisted.** `StatusText`, `StatusCriticality`,
`IsApprovable`, `IsRetriable` are CASE expressions in the root view; a transactional
query projection cannot hold expressions
[source: abap-docs /abap-docs-cloud/ABENCDS_DEFINE_VIEW_AS_PROJECTION]. They are
`field ( readonly )` and absent from the mapping — see step 4.
- **Action parameters are abstract entities** (`ZD_*`), one element each, because action
input must be an abstract entity or DDIC type
[source: abap-docs /abap-docs-cloud/ABENBDL_ACTION_INPUT_PARAM]. `markSynced` and
`markFailed` therefore take `SapChangeNumber` / `ErrorMessage` as parameters — the only
way an action can "set" them from the worker.
- **`internal update`** keeps `update` out of every OData surface while the actions and
the determination still update fields `IN LOCAL MODE`
[source: abap-docs /abap-docs-cloud/ABENBDL_INTERNAL].
- **RequestedBy** is the technical user of the creator (the worker's communication user in
the A2X flow). Change to `mandatory : create` if the Arena requester should be shown.
## Landmines learned live (KNOWLEDGE.md candidates)
- [2026-09-15] `x-ui-service` generator: every field needs `semanticType` (e.g. `custom`)
or validation fails with "Builtin type of field X is invalid"; semantic-key fields pass
without it. The generator prepends its own `Z` to the `prefix` (`Z` → `ZZ…`) and appends
`000` to every artifact name even with `suffix: ""`; service definition and UI binding
get the same name (`ZZUI_ARENA_CHANGE_APPRVL000_O4`). There is no delete tool in the ADT
MCP, so a naming mistake is permanent from the agent side.
- [2026-09-15] `create_object` for SRVD needs `sourceType` (`"S"` worked; omitted →
"serviceDefinitionInfo.sourceType is null"). BDEF/SRVB creation validates the referenced
DDLS/SRVD **active** version, so source-less objects block dependent creations.
- [2026-09-15] Activation URIs: see table above; BDEF is `.bdef.json`, table `.tabl.json`,
SAP object types live under `Business Services`.
- [2026-09-15] The MCP session id from `get_schema` `referenceContent` is single-use in
practice — fetch a fresh one before each `generate_objects` attempt.
@@ -0,0 +1,10 @@
/* TARGET OBJECT : service definition ZAPI_ARENA_CHANGE_APPROVAL (created empty via ADT MCP 2026-09-15)
abap: URI : abap:/repotree-v1/0M7DEJI/System%20Library/ZLOCAL/ZARENA_PLM/Business%20Services/Service%20Definitions/ZAPI_ARENA_CHANGE_APPROVAL/zapi_arena_change_approval.srvd.acds
ACTION : replace entire content, then activate (after ZC_CHANGEAPPROVAL_A2X view + BDEF).
Sources: as ZZUI_ARENA_CHANGE_APPRVL000_O4; odata_v4_webapi is the Web API contract [ABENSRVD_PROVIDER_CONTRACT]. */
@EndUserText.label: 'Arena change approval - Web API (worker)'
define service ZAPI_ARENA_CHANGE_APPROVAL
provider contracts odata_v4_webapi
{
expose ZC_CHANGEAPPROVAL_A2X as ChangeApproval;
}
@@ -0,0 +1,19 @@
// TARGET OBJECT : behavior definition ZC_CHANGEAPPROVAL_A2X (A2X projection BDEF) - DOES NOT EXIST YET.
// The ADT MCP refused to create it while ZC_CHANGEAPPROVAL_A2X (DDLS) had no active source
// ("The referenced STOB object ZC_CHANGEAPPROVAL_A2X does not exist", 2026-09-15).
// Create it after the projection view is active: ADT > New Behavior Definition on ZC_CHANGEAPPROVAL_A2X,
// implementation type Projection (or re-run abap_creation-create_object BDEF/BDO, see README), paste, activate.
// Sources: as ZZC_CHANGEAPPROVAL000. use create lets the worker stage records (ARN-010: the staged payload is what
// gets executed); the worker never sees approve/reject/retrySync (PRD RAP-6).
projection;
strict ( 2 );
define behavior for ZC_CHANGEAPPROVAL_A2X alias ChangeApproval
{
use etag;
use create;
use action markExecuting;
use action markSynced;
use action markFailed;
}
@@ -0,0 +1,10 @@
/* TARGET OBJECT : access control ZC_CHANGEAPPROVAL_A2X (created empty via ADT MCP 2026-09-15)
abap: URI : abap:/repotree-v1/0M7DEJI/System%20Library/ZLOCAL/ZARENA_PLM/Core%20Data%20Services/Access%20Controls/ZC_CHANGEAPPROVAL_A2X/zc_changeapproval_a2x.dcls.acds
ACTION : replace entire content, then activate (after ZC_CHANGEAPPROVAL_A2X view and ZZR_CHANGEAPPROVAL000 DCL).
Sources: as ZZC_CHANGEAPPROVAL000 DCL. */
@EndUserText.label: 'Access control for ZC_CHANGEAPPROVAL_A2X'
@MappingRole: true
define role ZC_CHANGEAPPROVAL_A2X {
grant select on ZC_CHANGEAPPROVAL_A2X
where inheriting conditions from entity ZZR_CHANGEAPPROVAL000;
}
@@ -0,0 +1,38 @@
/* TARGET OBJECT : data definition ZC_CHANGEAPPROVAL_A2X (A2X projection for the worker; created empty via ADT MCP 2026-09-15)
abap: URI : abap:/repotree-v1/0M7DEJI/System%20Library/ZLOCAL/ZARENA_PLM/Core%20Data%20Services/Data%20Definitions/ZC_CHANGEAPPROVAL_A2X/zc_changeapproval_a2x.ddls.acds
ACTION : replace entire content, then activate (after ZZR_CHANGEAPPROVAL000).
Notes: separate projection so the Web API exposes create + mark* while the UI projection stays read-only
(PRD RAP-2 / RAP-6); same syntax sources as ZZC_CHANGEAPPROVAL000. No metadata extension here -
a Web API binding carries no UI annotations [source: sap-docs sap-help loio 248b36b7ebce4f7e8c7670c9371adee1, verified 2026-09-15]. */
@EndUserText.label: 'Arena change approval - A2X projection (worker)'
@AccessControl.authorizationCheck: #CHECK
@ObjectModel.semanticKey: ['ItemNumber', 'ChangeType', 'ArenaEcoId']
define root view entity ZC_CHANGEAPPROVAL_A2X
provider contract transactional_query
as projection on ZZR_CHANGEAPPROVAL000
{
key ID,
ItemNumber,
ItemName,
ChangeType,
ArenaEcoId,
@ObjectModel.text.element: ['StatusText']
Status,
StatusText,
StatusCriticality,
RequestedBy,
RequestedOn,
DecidedBy,
DecidedOn,
SapChangeNumber,
ErrorMessage,
RejectReason,
SapPreview,
IsApprovable,
IsRetriable,
CreatedBy,
CreatedAt,
LastChangedBy,
LastChangedAt,
LocalLastChangedAt
}
@@ -0,0 +1,10 @@
/* TARGET OBJECT : data definition ZD_CHANGEAPPROVAL_FAILEDP (abstract entity = parameter of action markFailed; created empty via ADT MCP 2026-09-15)
abap: URI : abap:/repotree-v1/0M7DEJI/System%20Library/ZLOCAL/ZARENA_PLM/Core%20Data%20Services/Data%20Definitions/ZD_CHANGEAPPROVAL_FAILEDP/zd_changeapproval_failedp.ddls.acds
ACTION : replace entire content, then activate.
Sources: as ZD_CHANGEAPPROVAL_REJECTP. */
@EndUserText.label: 'Arena change approval: markFailed parameter'
define abstract entity ZD_CHANGEAPPROVAL_FAILEDP
{
@EndUserText.label: 'Error message'
ErrorMessage : abap.char(255);
}
@@ -0,0 +1,13 @@
/* TARGET OBJECT : data definition ZD_CHANGEAPPROVAL_REJECTP (abstract entity = parameter of action reject; created empty via ADT MCP 2026-09-15)
abap: URI : abap:/repotree-v1/0M7DEJI/System%20Library/ZLOCAL/ZARENA_PLM/Core%20Data%20Services/Data%20Definitions/ZD_CHANGEAPPROVAL_REJECTP/zd_changeapproval_rejectp.ddls.acds
ACTION : replace entire content, then activate.
Notes: action input parameters must be a CDS abstract entity or DDIC type; @EndUserText.label on the element
gives the UI label [source: abap-docs /abap-docs-cloud/ABENBDL_ACTION_INPUT_PARAM, verified 2026-09-15];
abstract-entity syntax [source: abap-docs /abap-docs-cloud/ABENBDL_ACTION2_ABEXA, verified 2026-09-15].
The OData parameter name is the element name -> RejectReason, as the Fiori app expects (PRD RAP-2). */
@EndUserText.label: 'Arena change approval: reject parameter'
define abstract entity ZD_CHANGEAPPROVAL_REJECTP
{
@EndUserText.label: 'Reject reason'
RejectReason : abap.char(255);
}
@@ -0,0 +1,10 @@
/* TARGET OBJECT : data definition ZD_CHANGEAPPROVAL_SYNCEDP (abstract entity = parameter of action markSynced; created empty via ADT MCP 2026-09-15)
abap: URI : abap:/repotree-v1/0M7DEJI/System%20Library/ZLOCAL/ZARENA_PLM/Core%20Data%20Services/Data%20Definitions/ZD_CHANGEAPPROVAL_SYNCEDP/zd_changeapproval_syncedp.ddls.acds
ACTION : replace entire content, then activate.
Sources: as ZD_CHANGEAPPROVAL_REJECTP. */
@EndUserText.label: 'Arena change approval: markSynced parameter'
define abstract entity ZD_CHANGEAPPROVAL_SYNCEDP
{
@EndUserText.label: 'SAP change number'
SapChangeNumber : abap.char(12);
}
@@ -0,0 +1,14 @@
" TARGET OBJECT : class ZZBP_R_CHANGEAPPROVAL000 - GLOBAL CLASS include (behavior pool; generated 2026-09-15)
" abap: URI : abap:/repotree-v1/0M7DEJI/System%20Library/ZLOCAL/ZARENA_PLM/Source%20Code%20Library/Classes/ZZBP_R_CHANGEAPPROVAL000/zzbp_r_changeapproval000.clas.abap
" ACTION : replace entire content of the global class; the handlers go into "Local Types" (locals_imp file).
" Source: FOR BEHAVIOR OF defines the ABAP behavior pool; the global class implements nothing itself
" [source: abap-docs /abap-docs-cloud/ABAPCLASS_FOR_BEHAVIOR_OF, ABENABAP_BEHAVIOR_POOLS, verified 2026-09-15].
CLASS zzbp_r_changeapproval000 DEFINITION
PUBLIC
ABSTRACT
FINAL
FOR BEHAVIOR OF zzr_changeapproval000.
ENDCLASS.
CLASS zzbp_r_changeapproval000 IMPLEMENTATION.
ENDCLASS.
@@ -0,0 +1,426 @@
" TARGET OBJECT : class ZZBP_R_CHANGEAPPROVAL000 - LOCAL TYPES include (Local Types tab in ADT)
" abap: URI : abap:/repotree-v1/0M7DEJI/System%20Library/ZLOCAL/ZARENA_PLM/Source%20Code%20Library/Classes/ZZBP_R_CHANGEAPPROVAL000/zzbp_r_changeapproval000.clas.locals_imp.abap
" ACTION : replace entire content, then activate the class (BDEF ZZR_CHANGEAPPROVAL000 must be active first).
"
" Sources (all verified 2026-09-15):
" handler class inherits from CL_ABAP_BEHAVIOR_HANDLER (released) [sap-released-objects public_cloud A]
" FOR INSTANCE FEATURES signature + result via %tky / %features-%action-<name> / if_abap_behv=>fc-o-enabled|disabled
" [abap-docs /abap-docs-cloud/ABAPHANDLER_METH_FEATURES, ABAPDERIVED_TYPES_FEATURES, ABENBDL_ACTIONS_FC]
" FOR MODIFY ... FOR ACTION with RESULT; %param carries the action parameter (keys) and the result ($self)
" [abap-docs /abap-docs-cloud/ABENBDL_ACTION2_ABEXA, ABAPDERIVED_TYPES_PARAM]
" READ ENTITIES ... IN LOCAL MODE ... FIELDS ( ) WITH CORRESPONDING #( keys ) RESULT ... FAILED failed
" [abap-docs /abap-docs-cloud/ABENBDL_ACTIONS_FC example]
" MODIFY ENTITIES ... IN LOCAL MODE (skips feature control and authorization) with UPDATE FIELDS
" [abap-docs /abap-docs-cloud/ABAPMODIFY_ENTITY_ENTITIES, ABAPMODIFY_ENTITY_ENTITIES_OP, ABENBDL_AUTHORIZATION]
" %msg = new_message_with_text( ... ) into reported [abap-docs /abap-docs-cloud/ABENBDL_ACTIONS_FC example]
" FOR DETERMINE ON MODIFY signature [abap-docs /abap-docs-cloud/ABAPHANDLER_METH_DET]
" utclong_current( ) [abap-docs /abap-docs-cloud/ABENUTCLONG_CURRENT]
" cl_abap_context_info=>get_user_technical_name( ) (released class) [sap-docs sap-help loio 7be24435b3e24540a9edad9a2535175a;
" sap-released-objects CL_ABAP_CONTEXT_INFO public_cloud A]
" Design notes:
" - Status transitions are enforced twice: get_instance_features disables the action (UI greys it out / EML fails
" with an entry in failed [ABENBDL_ACTIONS_FC]) AND each action re-checks the current status so an EML caller
" IN LOCAL MODE cannot skip the state machine.
" - The six actions repeat the same read/check/update/read pattern on purpose: each action has its own derived
" key type, so a generic helper would need additional derived-type plumbing that is not worth it for six methods.
" - RequestedBy is the technical user of the creator (for the A2X flow: the worker's communication user).
" If the Arena requester should be shown instead, make RequestedBy mandatory:create in the BDEF and drop it
" from the determination.
CLASS lhc_changeapproval DEFINITION INHERITING FROM cl_abap_behavior_handler.
PRIVATE SECTION.
CONSTANTS:
BEGIN OF status,
pending TYPE zzchngpproval000-status VALUE 'PENDING',
approved TYPE zzchngpproval000-status VALUE 'APPROVED',
executing TYPE zzchngpproval000-status VALUE 'EXECUTING',
synced TYPE zzchngpproval000-status VALUE 'SYNCED',
rejected TYPE zzchngpproval000-status VALUE 'REJECTED',
failed TYPE zzchngpproval000-status VALUE 'FAILED',
END OF status.
METHODS get_instance_features FOR INSTANCE FEATURES
IMPORTING keys REQUEST requested_features FOR ChangeApproval RESULT result.
METHODS approve FOR MODIFY
IMPORTING keys FOR ACTION ChangeApproval~approve RESULT result.
METHODS reject FOR MODIFY
IMPORTING keys FOR ACTION ChangeApproval~reject RESULT result.
METHODS retrySync FOR MODIFY
IMPORTING keys FOR ACTION ChangeApproval~retrySync RESULT result.
METHODS markExecuting FOR MODIFY
IMPORTING keys FOR ACTION ChangeApproval~markExecuting RESULT result.
METHODS markSynced FOR MODIFY
IMPORTING keys FOR ACTION ChangeApproval~markSynced RESULT result.
METHODS markFailed FOR MODIFY
IMPORTING keys FOR ACTION ChangeApproval~markFailed RESULT result.
METHODS setInitialStatus FOR DETERMINE ON MODIFY
IMPORTING keys FOR ChangeApproval~setInitialStatus.
ENDCLASS.
CLASS lhc_changeapproval IMPLEMENTATION.
METHOD get_instance_features.
READ ENTITIES OF zzr_changeapproval000 IN LOCAL MODE
ENTITY ChangeApproval
FIELDS ( Status )
WITH CORRESPONDING #( keys )
RESULT DATA(approvals)
FAILED failed.
result = VALUE #( FOR approval IN approvals
( %tky = approval-%tky
%features-%action-approve = COND #( WHEN approval-Status = status-pending
THEN if_abap_behv=>fc-o-enabled
ELSE if_abap_behv=>fc-o-disabled )
%features-%action-reject = COND #( WHEN approval-Status = status-pending
THEN if_abap_behv=>fc-o-enabled
ELSE if_abap_behv=>fc-o-disabled )
%features-%action-retrySync = COND #( WHEN approval-Status = status-failed
THEN if_abap_behv=>fc-o-enabled
ELSE if_abap_behv=>fc-o-disabled )
%features-%action-markExecuting = COND #( WHEN approval-Status = status-approved
THEN if_abap_behv=>fc-o-enabled
ELSE if_abap_behv=>fc-o-disabled )
%features-%action-markSynced = COND #( WHEN approval-Status = status-executing
THEN if_abap_behv=>fc-o-enabled
ELSE if_abap_behv=>fc-o-disabled )
%features-%action-markFailed = COND #( WHEN approval-Status = status-executing
THEN if_abap_behv=>fc-o-enabled
ELSE if_abap_behv=>fc-o-disabled ) ) ).
ENDMETHOD.
METHOD approve.
" PENDING -> APPROVED; records the decision, never writes SapChangeNumber (PRD RAP-1)
READ ENTITIES OF zzr_changeapproval000 IN LOCAL MODE
ENTITY ChangeApproval
FIELDS ( Status )
WITH CORRESPONDING #( keys )
RESULT DATA(approvals)
FAILED failed.
LOOP AT keys INTO DATA(key).
ASSIGN approvals[ ID = key-ID ] TO FIELD-SYMBOL(<approval>).
IF sy-subrc <> 0.
CONTINUE.
ENDIF.
IF <approval>-Status <> status-pending.
APPEND VALUE #( %tky = <approval>-%tky ) TO failed-changeapproval.
APPEND VALUE #( %tky = <approval>-%tky
%msg = new_message_with_text(
severity = if_abap_behv_message=>severity-error
text = |Only PENDING records can be approved (status is { <approval>-Status })| ) )
TO reported-changeapproval.
CONTINUE.
ENDIF.
MODIFY ENTITIES OF zzr_changeapproval000 IN LOCAL MODE
ENTITY ChangeApproval
UPDATE FIELDS ( Status DecidedBy DecidedOn )
WITH VALUE #( ( %tky = <approval>-%tky
Status = status-approved
DecidedBy = cl_abap_context_info=>get_user_technical_name( )
DecidedOn = utclong_current( ) ) )
FAILED DATA(update_failed)
REPORTED DATA(update_reported).
ENDLOOP.
READ ENTITIES OF zzr_changeapproval000 IN LOCAL MODE
ENTITY ChangeApproval
ALL FIELDS WITH CORRESPONDING #( keys )
RESULT DATA(updated).
result = VALUE #( FOR row IN updated ( %tky = row-%tky %param = row ) ).
ENDMETHOD.
METHOD reject.
" PENDING -> REJECTED with a mandatory reason (PRD RAP-2); REJECTED is terminal
READ ENTITIES OF zzr_changeapproval000 IN LOCAL MODE
ENTITY ChangeApproval
FIELDS ( Status )
WITH CORRESPONDING #( keys )
RESULT DATA(approvals)
FAILED failed.
LOOP AT keys INTO DATA(key).
ASSIGN approvals[ ID = key-ID ] TO FIELD-SYMBOL(<approval>).
IF sy-subrc <> 0.
CONTINUE.
ENDIF.
IF <approval>-Status <> status-pending.
APPEND VALUE #( %tky = <approval>-%tky ) TO failed-changeapproval.
APPEND VALUE #( %tky = <approval>-%tky
%msg = new_message_with_text(
severity = if_abap_behv_message=>severity-error
text = |Only PENDING records can be rejected (status is { <approval>-Status })| ) )
TO reported-changeapproval.
CONTINUE.
ENDIF.
IF key-%param-RejectReason IS INITIAL.
APPEND VALUE #( %tky = <approval>-%tky ) TO failed-changeapproval.
APPEND VALUE #( %tky = <approval>-%tky
%msg = new_message_with_text(
severity = if_abap_behv_message=>severity-error
text = 'A reject reason is required' ) )
TO reported-changeapproval.
CONTINUE.
ENDIF.
MODIFY ENTITIES OF zzr_changeapproval000 IN LOCAL MODE
ENTITY ChangeApproval
UPDATE FIELDS ( Status RejectReason DecidedBy DecidedOn )
WITH VALUE #( ( %tky = <approval>-%tky
Status = status-rejected
RejectReason = key-%param-RejectReason
DecidedBy = cl_abap_context_info=>get_user_technical_name( )
DecidedOn = utclong_current( ) ) )
FAILED DATA(update_failed)
REPORTED DATA(update_reported).
ENDLOOP.
READ ENTITIES OF zzr_changeapproval000 IN LOCAL MODE
ENTITY ChangeApproval
ALL FIELDS WITH CORRESPONDING #( keys )
RESULT DATA(updated).
result = VALUE #( FOR row IN updated ( %tky = row-%tky %param = row ) ).
ENDMETHOD.
METHOD retrySync.
" FAILED -> EXECUTING; clears the previous error so the worker re-executes the staged payload (ARN-002 amendment)
READ ENTITIES OF zzr_changeapproval000 IN LOCAL MODE
ENTITY ChangeApproval
FIELDS ( Status )
WITH CORRESPONDING #( keys )
RESULT DATA(approvals)
FAILED failed.
LOOP AT keys INTO DATA(key).
ASSIGN approvals[ ID = key-ID ] TO FIELD-SYMBOL(<approval>).
IF sy-subrc <> 0.
CONTINUE.
ENDIF.
IF <approval>-Status <> status-failed.
APPEND VALUE #( %tky = <approval>-%tky ) TO failed-changeapproval.
APPEND VALUE #( %tky = <approval>-%tky
%msg = new_message_with_text(
severity = if_abap_behv_message=>severity-error
text = |Only FAILED records can be retried (status is { <approval>-Status })| ) )
TO reported-changeapproval.
CONTINUE.
ENDIF.
MODIFY ENTITIES OF zzr_changeapproval000 IN LOCAL MODE
ENTITY ChangeApproval
UPDATE FIELDS ( Status ErrorMessage )
WITH VALUE #( ( %tky = <approval>-%tky
Status = status-executing
ErrorMessage = '' ) )
FAILED DATA(update_failed)
REPORTED DATA(update_reported).
ENDLOOP.
READ ENTITIES OF zzr_changeapproval000 IN LOCAL MODE
ENTITY ChangeApproval
ALL FIELDS WITH CORRESPONDING #( keys )
RESULT DATA(updated).
result = VALUE #( FOR row IN updated ( %tky = row-%tky %param = row ) ).
ENDMETHOD.
METHOD markExecuting.
" APPROVED -> EXECUTING (worker claims the record; the UI never offers Approve on an executing record, PRD RAP-6)
READ ENTITIES OF zzr_changeapproval000 IN LOCAL MODE
ENTITY ChangeApproval
FIELDS ( Status )
WITH CORRESPONDING #( keys )
RESULT DATA(approvals)
FAILED failed.
LOOP AT keys INTO DATA(key).
ASSIGN approvals[ ID = key-ID ] TO FIELD-SYMBOL(<approval>).
IF sy-subrc <> 0.
CONTINUE.
ENDIF.
IF <approval>-Status <> status-approved.
APPEND VALUE #( %tky = <approval>-%tky ) TO failed-changeapproval.
APPEND VALUE #( %tky = <approval>-%tky
%msg = new_message_with_text(
severity = if_abap_behv_message=>severity-error
text = |Only APPROVED records can start executing (status is { <approval>-Status })| ) )
TO reported-changeapproval.
CONTINUE.
ENDIF.
MODIFY ENTITIES OF zzr_changeapproval000 IN LOCAL MODE
ENTITY ChangeApproval
UPDATE FIELDS ( Status )
WITH VALUE #( ( %tky = <approval>-%tky
Status = status-executing ) )
FAILED DATA(update_failed)
REPORTED DATA(update_reported).
ENDLOOP.
READ ENTITIES OF zzr_changeapproval000 IN LOCAL MODE
ENTITY ChangeApproval
ALL FIELDS WITH CORRESPONDING #( keys )
RESULT DATA(updated).
result = VALUE #( FOR row IN updated ( %tky = row-%tky %param = row ) ).
ENDMETHOD.
METHOD markSynced.
" EXECUTING -> SYNCED (terminal success); SAP is the ledger, the change number is the receipt (ARN-006)
READ ENTITIES OF zzr_changeapproval000 IN LOCAL MODE
ENTITY ChangeApproval
FIELDS ( Status )
WITH CORRESPONDING #( keys )
RESULT DATA(approvals)
FAILED failed.
LOOP AT keys INTO DATA(key).
ASSIGN approvals[ ID = key-ID ] TO FIELD-SYMBOL(<approval>).
IF sy-subrc <> 0.
CONTINUE.
ENDIF.
IF <approval>-Status <> status-executing.
APPEND VALUE #( %tky = <approval>-%tky ) TO failed-changeapproval.
APPEND VALUE #( %tky = <approval>-%tky
%msg = new_message_with_text(
severity = if_abap_behv_message=>severity-error
text = |Only EXECUTING records can be marked synced (status is { <approval>-Status })| ) )
TO reported-changeapproval.
CONTINUE.
ENDIF.
IF key-%param-SapChangeNumber IS INITIAL.
APPEND VALUE #( %tky = <approval>-%tky ) TO failed-changeapproval.
APPEND VALUE #( %tky = <approval>-%tky
%msg = new_message_with_text(
severity = if_abap_behv_message=>severity-error
text = 'The SAP change number is required to mark a record synced' ) )
TO reported-changeapproval.
CONTINUE.
ENDIF.
MODIFY ENTITIES OF zzr_changeapproval000 IN LOCAL MODE
ENTITY ChangeApproval
UPDATE FIELDS ( Status SapChangeNumber ErrorMessage )
WITH VALUE #( ( %tky = <approval>-%tky
Status = status-synced
SapChangeNumber = key-%param-SapChangeNumber
ErrorMessage = '' ) )
FAILED DATA(update_failed)
REPORTED DATA(update_reported).
ENDLOOP.
READ ENTITIES OF zzr_changeapproval000 IN LOCAL MODE
ENTITY ChangeApproval
ALL FIELDS WITH CORRESPONDING #( keys )
RESULT DATA(updated).
result = VALUE #( FOR row IN updated ( %tky = row-%tky %param = row ) ).
ENDMETHOD.
METHOD markFailed.
" EXECUTING -> FAILED with the error text; distinct from REJECTED (predecessor defect, ARN-002)
READ ENTITIES OF zzr_changeapproval000 IN LOCAL MODE
ENTITY ChangeApproval
FIELDS ( Status )
WITH CORRESPONDING #( keys )
RESULT DATA(approvals)
FAILED failed.
LOOP AT keys INTO DATA(key).
ASSIGN approvals[ ID = key-ID ] TO FIELD-SYMBOL(<approval>).
IF sy-subrc <> 0.
CONTINUE.
ENDIF.
IF <approval>-Status <> status-executing.
APPEND VALUE #( %tky = <approval>-%tky ) TO failed-changeapproval.
APPEND VALUE #( %tky = <approval>-%tky
%msg = new_message_with_text(
severity = if_abap_behv_message=>severity-error
text = |Only EXECUTING records can be marked failed (status is { <approval>-Status })| ) )
TO reported-changeapproval.
CONTINUE.
ENDIF.
IF key-%param-ErrorMessage IS INITIAL.
APPEND VALUE #( %tky = <approval>-%tky ) TO failed-changeapproval.
APPEND VALUE #( %tky = <approval>-%tky
%msg = new_message_with_text(
severity = if_abap_behv_message=>severity-error
text = 'An error message is required to mark a record failed' ) )
TO reported-changeapproval.
CONTINUE.
ENDIF.
MODIFY ENTITIES OF zzr_changeapproval000 IN LOCAL MODE
ENTITY ChangeApproval
UPDATE FIELDS ( Status ErrorMessage )
WITH VALUE #( ( %tky = <approval>-%tky
Status = status-failed
ErrorMessage = key-%param-ErrorMessage ) )
FAILED DATA(update_failed)
REPORTED DATA(update_reported).
ENDLOOP.
READ ENTITIES OF zzr_changeapproval000 IN LOCAL MODE
ENTITY ChangeApproval
ALL FIELDS WITH CORRESPONDING #( keys )
RESULT DATA(updated).
result = VALUE #( FOR row IN updated ( %tky = row-%tky %param = row ) ).
ENDMETHOD.
METHOD setInitialStatus.
" Every created record starts PENDING with the requester audit fields (ARN-002)
READ ENTITIES OF zzr_changeapproval000 IN LOCAL MODE
ENTITY ChangeApproval
FIELDS ( Status )
WITH CORRESPONDING #( keys )
RESULT DATA(approvals).
DELETE approvals WHERE Status IS NOT INITIAL.
IF approvals IS INITIAL.
RETURN.
ENDIF.
MODIFY ENTITIES OF zzr_changeapproval000 IN LOCAL MODE
ENTITY ChangeApproval
UPDATE FIELDS ( Status RequestedBy RequestedOn )
WITH VALUE #( FOR approval IN approvals
( %tky = approval-%tky
Status = status-pending
RequestedBy = cl_abap_context_info=>get_user_technical_name( )
RequestedOn = utclong_current( ) ) )
REPORTED DATA(update_reported).
reported = CORRESPONDING #( DEEP update_reported ).
ENDMETHOD.
ENDCLASS.
@@ -0,0 +1,20 @@
// TARGET OBJECT : behavior definition ZZC_CHANGEAPPROVAL000 (UI projection BDEF; generated 2026-09-15)
// abap: URI : abap:/repotree-v1/0M7DEJI/System%20Library/ZLOCAL/ZARENA_PLM/Core%20Data%20Services/Behavior%20Definitions/ZZC_CHANGEAPPROVAL000/zzc_changeapproval000.bdef.json
// ACTION : replace entire content, then activate (after ZZR_CHANGEAPPROVAL000 BDEF + ZZC_CHANGEAPPROVAL000 view).
// Then DELETE the generated class ZZBP_C_CHANGEAPPROVAL000 in ADT: a projection BDEF needs no behavior
// pool unless it defines new actions/functions, and this one reuses everything
// [source: abap-docs /abap-docs-cloud/ABENBDL_BDEF_PROJECTION_HEADER, verified 2026-09-15].
// Sources (verified 2026-09-15): use action / use etag; no automatic inheritance - only what is listed is exposed,
// which is how create/update/delete and the worker actions are kept out of the UI service (PRD RAP-2)
// [abap-docs /abap-docs-cloud/ABENBDL_USE_PROJECTION]. Feature control of reused actions is passed on automatically (same source).
projection;
strict ( 2 );
define behavior for ZZC_CHANGEAPPROVAL000 alias ChangeApproval
{
use etag;
use action approve;
use action reject;
use action retrySync;
}
@@ -0,0 +1,12 @@
/* TARGET OBJECT : access control ZZC_CHANGEAPPROVAL000 (generated 2026-09-15)
abap: URI : abap:/repotree-v1/0M7DEJI/System%20Library/ZLOCAL/ZARENA_PLM/Core%20Data%20Services/Access%20Controls/ZZC_CHANGEAPPROVAL000/zzc_changeapproval000.dcls.acds
ACTION : replace entire content, then activate (after ZZR_CHANGEAPPROVAL000 DCL).
A transactional query needs its own DCL, inheriting the base view's conditions - there is no implicit inheritance
[source: abap-docs /abap-platform-rap-opensap/week2/unit7#step-5-create-cds-access-control-for-travel-bo-projection-view-and-check-the-result;
/abap-docs-cloud/ABENCDS_DEFINE_VIEW_AS_PROJECTION (access control must be symmetric), verified 2026-09-15]. */
@EndUserText.label: 'Access control for ZZC_CHANGEAPPROVAL000'
@MappingRole: true
define role ZZC_CHANGEAPPROVAL000 {
grant select on ZZC_CHANGEAPPROVAL000
where inheriting conditions from entity ZZR_CHANGEAPPROVAL000;
}
@@ -0,0 +1,35 @@
/* TARGET OBJECT : data definition ZZC_CHANGEAPPROVAL000 (UI projection, generated 2026-09-15)
abap: URI : abap:/repotree-v1/0M7DEJI/System%20Library/ZLOCAL/ZARENA_PLM/Core%20Data%20Services/Data%20Definitions/ZZC_CHANGEAPPROVAL000/zzc_changeapproval000.ddls.acds
ACTION : replace entire content, then activate (after ZZR_CHANGEAPPROVAL000).
Notes: provider contract transactional_query [source: abap-docs /abap-docs-cloud/ABENCDS_DEFINE_VIEW_AS_PROJECTION, verified 2026-09-15];
@ObjectModel.semanticKey (PRD RAP-8) [source: abap-docs /abap-fiori-showcase/05_object_page_header#handling-semantic-key-fields, verified 2026-09-15];
@Metadata.allowExtensions is required for the metadata extension [source: abap-docs /abap-docs-cloud/ABENCDS_F1_ANNOTATE_VIEW, verified 2026-09-15]. */
@EndUserText.label: 'Arena change approval - UI projection'
@AccessControl.authorizationCheck: #CHECK
@Metadata.allowExtensions: true
@ObjectModel.semanticKey: ['ItemNumber', 'ChangeType', 'ArenaEcoId']
define root view entity ZZC_CHANGEAPPROVAL000
provider contract transactional_query
as projection on ZZR_CHANGEAPPROVAL000
{
key ID,
ItemNumber,
ItemName,
ChangeType,
ArenaEcoId,
@ObjectModel.text.element: ['StatusText']
Status,
StatusText,
StatusCriticality,
RequestedBy,
RequestedOn,
DecidedBy,
DecidedOn,
SapChangeNumber,
ErrorMessage,
RejectReason,
SapPreview,
IsApprovable,
IsRetriable,
LocalLastChangedAt
}
@@ -0,0 +1,142 @@
/* TARGET OBJECT : metadata extension ZZC_CHANGEAPPROVAL000 (generated 2026-09-15)
abap: URI : abap:/repotree-v1/0M7DEJI/System%20Library/ZLOCAL/ZARENA_PLM/Core%20Data%20Services/Metadata%20Extensions/ZZC_CHANGEAPPROVAL000/zzc_changeapproval000.ddlx.acds
ACTION : replace entire content, then activate (after ZZC_CHANGEAPPROVAL000 view).
Mirrors app/webapp/annotations/annotation.xml (line items, filter bar, header, facets, actions); the app's local
annotations still apply on top at runtime. Labels are literals here; the app carries the i18n texts.
Sources (all verified 2026-09-15, abap-docs /abap-fiori-showcase):
@Metadata.layer + ANNOTATE ENTITY [abap-docs /abap-docs-cloud/ABENCDS_F1_ANNOTATE_VIEW]
@UI.headerInfo typeName/typeNamePlural/typeImageUrl/title/description [05_object_page_header#title-and-subtitle]
@UI.presentationVariant sortOrder + visualizations [03_list_report_content (Defining the Default Sort Order)]
@UI.facet purpose/type/id/label/position/targetQualifier [06_object_page_content (Nested Tabs, RAPTreeview)]
@UI.dataPoint qualifier/title/criticality + #DATAPOINT_REFERENCE header facet [05_object_page_header (Key Value, Progress)]
@UI.identification #FOR_ACTION dataAction/label [05_object_page_header (Regular Actions)]
@UI.lineItem position/importance/criticality [03_list_report_content (Configuring Tables); 06 (Table)]
@UI.selectionField position [02_list_report_header#selection-fields]
@UI.fieldGroup qualifier/position [06_object_page_content (Multi Input Field)]
@UI.textArrangement #TEXT_ONLY [06_object_page_content#displaying-text-instead-of-id]
@UI.multiLineText [05_object_page_header (Plain Text)]; @UI.hidden [06_object_page_content (Hiding Features)]
Default filter Status = PENDING is NOT set here (string-literal escaping inside @UI.selectionVariant.filter is
unverified); the app's annotation.xml SelectionVariant#Default provides it. */
@Metadata.layer: #CUSTOMER
@UI: {
headerInfo: {
typeName: 'Change Approval',
typeNamePlural: 'Change Approvals',
typeImageUrl: 'sap-icon://approvals',
title: { type: #STANDARD, value: 'ItemNumber' },
description: { type: #STANDARD, value: 'ItemName' }
},
presentationVariant: [ {
sortOrder: [ { by: 'Status', direction: #ASC }, { by: 'RequestedOn', direction: #DESC } ],
visualizations: [ { type: #AS_LINEITEM } ]
} ]
}
annotate entity ZZC_CHANGEAPPROVAL000 with
{
@UI.facet: [
{ id: 'StatusPoint', purpose: #HEADER, type: #DATAPOINT_REFERENCE, targetQualifier: 'StatusPoint' },
{ id: 'Keys', purpose: #HEADER, type: #FIELDGROUP_REFERENCE, targetQualifier: 'Keys', label: 'Reference numbers' },
{ id: 'Change', purpose: #STANDARD, type: #FIELDGROUP_REFERENCE, targetQualifier: 'Change', label: 'Change', position: 10 },
{ id: 'Payload', purpose: #STANDARD, type: #FIELDGROUP_REFERENCE, targetQualifier: 'Payload', label: 'Staged payload', position: 20 },
{ id: 'Approval', purpose: #STANDARD, type: #FIELDGROUP_REFERENCE, targetQualifier: 'Approval', label: 'Approval', position: 30 },
{ id: 'Execution', purpose: #STANDARD, type: #FIELDGROUP_REFERENCE, targetQualifier: 'Execution', label: 'Execution', position: 40 }
]
@UI.identification: [
{ position: 10, type: #FOR_ACTION, dataAction: 'approve', label: 'Approve' },
{ position: 20, type: #FOR_ACTION, dataAction: 'reject', label: 'Reject' },
{ position: 30, type: #FOR_ACTION, dataAction: 'retrySync', label: 'Retry sync' }
]
@UI.hidden: true
ID;
@UI: {
lineItem: [ { position: 10, importance: #HIGH } ],
selectionField: [ { position: 10 } ],
fieldGroup: [ { qualifier: 'Change', position: 10 } ]
}
@EndUserText.label: 'Item number'
ItemNumber;
@UI: {
lineItem: [ { position: 20, importance: #LOW } ],
fieldGroup: [ { qualifier: 'Change', position: 20 } ]
}
@EndUserText.label: 'Item name'
ItemName;
@UI: {
lineItem: [ { position: 30, importance: #HIGH } ],
selectionField: [ { position: 30 } ],
fieldGroup: [ { qualifier: 'Change', position: 30 } ]
}
@EndUserText.label: 'Change type'
ChangeType;
@UI: {
lineItem: [ { position: 60, importance: #MEDIUM } ],
selectionField: [ { position: 40 } ],
fieldGroup: [ { qualifier: 'Change', position: 40 }, { qualifier: 'Keys', position: 10 } ]
}
@EndUserText.label: 'Arena ECO'
ArenaEcoId;
@UI: {
lineItem: [ { position: 40, importance: #HIGH, criticality: 'StatusCriticality' } ],
selectionField: [ { position: 20 } ],
fieldGroup: [ { qualifier: 'Approval', position: 10, criticality: 'StatusCriticality' } ],
dataPoint: { qualifier: 'StatusPoint', title: 'Status', criticality: 'StatusCriticality' },
textArrangement: #TEXT_ONLY
}
@EndUserText.label: 'Status'
Status;
@UI.hidden: true
StatusText;
@UI.hidden: true
StatusCriticality;
@UI.fieldGroup: [ { qualifier: 'Approval', position: 20 } ]
@EndUserText.label: 'Requested by'
RequestedBy;
@UI: {
lineItem: [ { position: 50, importance: #MEDIUM } ],
fieldGroup: [ { qualifier: 'Approval', position: 30 } ]
}
@EndUserText.label: 'Requested on'
RequestedOn;
@UI.fieldGroup: [ { qualifier: 'Approval', position: 40 } ]
@EndUserText.label: 'Decided by'
DecidedBy;
@UI.fieldGroup: [ { qualifier: 'Approval', position: 50 } ]
@EndUserText.label: 'Decided on'
DecidedOn;
@UI.fieldGroup: [ { qualifier: 'Approval', position: 60 } ]
@EndUserText.label: 'Reject reason'
RejectReason;
@UI.fieldGroup: [ { qualifier: 'Execution', position: 10 }, { qualifier: 'Keys', position: 20 } ]
@EndUserText.label: 'SAP change number'
SapChangeNumber;
@UI.fieldGroup: [ { qualifier: 'Execution', position: 20 } ]
@EndUserText.label: 'Error message'
ErrorMessage;
@UI: {
fieldGroup: [ { qualifier: 'Payload', position: 10 } ],
multiLineText: true
}
@EndUserText.label: 'Staged payload'
SapPreview;
@UI.hidden: true
IsApprovable;
@UI.hidden: true
IsRetriable;
}
@@ -0,0 +1,35 @@
/* TARGET OBJECT : database table ZZCHNGPPROVAL000 (generated 2026-09-15 by x-ui-service)
abap: URI : abap:/repotree-v1/0M7DEJI/System%20Library/ZLOCAL/ZARENA_PLM/Dictionary/Database%20Tables/ZZCHNGPPROVAL000/zzchngpproval000.tabl.json
ACTION : replace entire content, then activate.
Notes: sap_preview is abap.string(0) (RAP-7; char is capped at 1333 for table fields
[source: abap-docs /abap-docs-cloud/ABENDDIC_BUILTIN_TYPES, verified 2026-09-15];
abap.string(0) table-field syntax [source: abap-docs /abap-docs-cloud/ABENDDICDDL_DEFINE_TABLE_COMPS, verified 2026-09-15]).
Timestamps are abap.utclong (UTCLONG built-in type, same source). Admin fields use the released
ABP_* data elements and SYSUUID_X16 [source: sap-released-objects public_cloud level A, verified 2026-09-15]. */
@EndUserText.label : 'Arena change approval (staging + state machine)'
@AbapCatalog.enhancement.category : #NOT_EXTENSIBLE
@AbapCatalog.tableCategory : #TRANSPARENT
@AbapCatalog.deliveryClass : #A
@AbapCatalog.dataMaintenance : #RESTRICTED
define table zzchngpproval000 {
key client : abap.clnt not null;
key id : sysuuid_x16 not null;
item_number : abap.char(18);
item_name : abap.char(40);
change_type : abap.char(15);
arena_eco_id : abap.char(20);
status : abap.char(10);
requested_by : abap.char(12);
requested_on : abap.utclong;
decided_by : abap.char(12);
decided_on : abap.utclong;
sap_change_number : abap.char(12);
error_message : abap.char(255);
reject_reason : abap.char(255);
sap_preview : abap.string(0);
created_by : abp_creation_user;
created_at : abp_creation_tstmpl;
last_changed_by : abp_lastchange_user;
last_changed_at : abp_lastchange_tstmpl;
local_last_changed_at : abp_locinst_lastchange_tstmpl;
}
@@ -0,0 +1,73 @@
// TARGET OBJECT : behavior definition ZZR_CHANGEAPPROVAL000 (base BDEF, managed; generated 2026-09-15)
// abap: URI : abap:/repotree-v1/0M7DEJI/System%20Library/ZLOCAL/ZARENA_PLM/Core%20Data%20Services/Behavior%20Definitions/ZZR_CHANGEAPPROVAL000/zzr_changeapproval000.bdef.json
// ACTION : replace entire content, then activate (after the three ZD_* abstract entities and ZZR_CHANGEAPPROVAL000).
// Sources (all verified 2026-09-15):
// action syntax incl. ( features : instance ), parameter, result [1] $self
// [abap-docs /abap-docs-cloud/ABENBDL_ACTION, ABENBDL_ACTIONS_FC, ABENBDL_ACTION_INPUT_PARAM, ABENBDL_ACTION2_ABEXA]
// internal operations (usable only from the behavior pool) [abap-docs /abap-docs-cloud/ABENBDL_INTERNAL]
// field ( readonly, numbering : managed ) for a UUID key [abap-docs /abap-docs-cloud/ABENBDL_FIELD_NUMBERING; /abap-fiori-showcase/01_general_features#readonly]
// field ( readonly : update, mandatory : create ) combination [abap-docs /abap-fiori-showcase/06_object_page_content (StreamIsReadOnly)]
// determination on modify { create; } sets the initial status [abap-docs /abap-docs-cloud/ABENBDL_DETERMINATIONS (setStatustoNew)]
// readonly fields written by determinations/actions IN LOCAL MODE [same source: field ( readonly ) AmountSum + TotalPrice determination]
// etag master [abap-docs /abap-docs-cloud/ABENBDL_ETAG]; explicit mapping because CDS aliases differ from table fields [abap-docs /abap-docs-cloud/ABENBDL_TYPE_MAPPING]
// authorization master ( none ): "no authorization checks for the entire RAP BO" - access is governed exclusively by the
// IAM app / business catalog (ADR-002, ARN-007) and read access by DCL [abap-docs /abap-docs-cloud/ABENBDL_AUTHORIZATION]
// Open point (falsifiable at activation): StatusText/StatusCriticality/IsApprovable/IsRetriable are CDS-calculated and
// deliberately absent from the mapping. ABENBDL_TYPE_MAPPING only requires a mapping where table and entity field
// NAMES differ; it is silent on calculated elements. If activation reports an error (not a warning) for the unmapped
// elements, fall back to persisting them (add the four columns to the table + mapping) and maintain them in the actions.
managed implementation in class zzbp_r_changeapproval000 unique;
strict ( 2 );
define behavior for ZZR_CHANGEAPPROVAL000 alias ChangeApproval
persistent table zzchngpproval000
lock master
authorization master ( none )
etag master LocalLastChangedAt
{
create;
internal update;
field ( readonly, numbering : managed ) ID;
field ( readonly : update, mandatory : create ) ItemNumber, ChangeType, ArenaEcoId;
field ( mandatory : create ) SapPreview;
field ( readonly ) Status, StatusText, StatusCriticality, IsApprovable, IsRetriable,
RequestedBy, RequestedOn, DecidedBy, DecidedOn,
SapChangeNumber, ErrorMessage, RejectReason,
CreatedBy, CreatedAt, LastChangedBy, LastChangedAt, LocalLastChangedAt;
// Business-user actions (UI projection): PENDING -> APPROVED | REJECTED, FAILED -> EXECUTING (PRD RAP-1/RAP-2)
action ( features : instance ) approve result [1] $self;
action ( features : instance ) reject parameter ZD_CHANGEAPPROVAL_REJECTP result [1] $self;
action ( features : instance ) retrySync result [1] $self;
// Worker actions (A2X projection): APPROVED -> EXECUTING -> SYNCED | FAILED (PRD RAP-6)
action ( features : instance ) markExecuting result [1] $self;
action ( features : instance ) markSynced parameter ZD_CHANGEAPPROVAL_SYNCEDP result [1] $self;
action ( features : instance ) markFailed parameter ZD_CHANGEAPPROVAL_FAILEDP result [1] $self;
determination setInitialStatus on modify { create; }
mapping for zzchngpproval000
{
ID = id;
ItemNumber = item_number;
ItemName = item_name;
ChangeType = change_type;
ArenaEcoId = arena_eco_id;
Status = status;
RequestedBy = requested_by;
RequestedOn = requested_on;
DecidedBy = decided_by;
DecidedOn = decided_on;
SapChangeNumber = sap_change_number;
ErrorMessage = error_message;
RejectReason = reject_reason;
SapPreview = sap_preview;
CreatedBy = created_by;
CreatedAt = created_at;
LastChangedBy = last_changed_by;
LastChangedAt = last_changed_at;
LocalLastChangedAt = local_last_changed_at;
}
}
@@ -0,0 +1,12 @@
/* TARGET OBJECT : access control ZZR_CHANGEAPPROVAL000 (generated 2026-09-15)
abap: URI : abap:/repotree-v1/0M7DEJI/System%20Library/ZLOCAL/ZARENA_PLM/Core%20Data%20Services/Access%20Controls/ZZR_CHANGEAPPROVAL000/zzr_changeapproval000.dcls.acds
ACTION : replace entire content, then activate.
Full access rule: read access is governed by the IAM app / business catalog (ADR-002); SAP delivers no full-access
roles but customers may use them [source: abap-docs /abap-docs-cloud/ABENCDS_DCL_ROLE_GRANT_RULE, verified 2026-09-15].
@MappingRole: true assigns the role to every user [source: abap-docs /abap-platform-rap-opensap/week2/unit7, verified 2026-09-15].
Tighten later with a custom authorization object + pfcg_auth condition if per-user data restrictions are needed. */
@EndUserText.label: 'Access control for ZZR_CHANGEAPPROVAL000'
@MappingRole: true
define role ZZR_CHANGEAPPROVAL000 {
grant select on ZZR_CHANGEAPPROVAL000;
}
@@ -0,0 +1,76 @@
/* TARGET OBJECT : data definition ZZR_CHANGEAPPROVAL000 (root view entity, generated 2026-09-15)
abap: URI : abap:/repotree-v1/0M7DEJI/System%20Library/ZLOCAL/ZARENA_PLM/Core%20Data%20Services/Data%20Definitions/ZZR_CHANGEAPPROVAL000/zzr_changeapproval000.ddls.acds
ACTION : replace entire content, then activate (table ZZCHNGPPROVAL000 must be active first).
Notes: StatusText / StatusCriticality / IsApprovable / IsRetriable are derived from Status with CASE
expressions here, because a transactional_query projection list may only contain elements of the
projected entity, path expressions, associations and virtual elements - no expressions
[source: abap-docs /abap-docs-cloud/ABENCDS_DEFINE_VIEW_AS_PROJECTION, verified 2026-09-15].
CASE syntax [source: abap-docs /abap-docs-cloud/ABENCDS_CASE_EXPRESSION_V2, verified 2026-09-15];
cast of a literal to a data element (abap_boolean, released) [source: abap-docs /abap-docs-cloud/ABENCDS_CAST_EXPRESSION_V2, verified 2026-09-15].
@ObjectModel.text.element [source: abap-docs /abap-fiori-showcase/06_object_page_content#displaying-text-instead-of-id, verified 2026-09-15].
@Semantics admin-field annotations [source: abap-docs /abap-platform-rap-opensap/week2/unit3#step-1-create-the-interface-cds-view-for-the-travel-entity, verified 2026-09-15].
Criticality values 2/2/5/3/1/1 per PRD RAP-1. */
@AccessControl.authorizationCheck: #CHECK
@EndUserText.label: 'Arena change approval'
define root view entity ZZR_CHANGEAPPROVAL000
as select from zzchngpproval000
{
key id as ID,
item_number as ItemNumber,
item_name as ItemName,
change_type as ChangeType,
arena_eco_id as ArenaEcoId,
@ObjectModel.text.element: ['StatusText']
status as Status,
case status
when 'PENDING' then 'Pending approval'
when 'APPROVED' then 'Approved - awaiting execution'
when 'EXECUTING' then 'Executing in SAP'
when 'SYNCED' then 'Synced to SAP'
when 'REJECTED' then 'Rejected'
when 'FAILED' then 'Execution failed'
else status
end as StatusText,
case status
when 'PENDING' then 2
when 'APPROVED' then 2
when 'EXECUTING' then 5
when 'SYNCED' then 3
when 'REJECTED' then 1
when 'FAILED' then 1
else 0
end as StatusCriticality,
requested_by as RequestedBy,
requested_on as RequestedOn,
decided_by as DecidedBy,
decided_on as DecidedOn,
sap_change_number as SapChangeNumber,
error_message as ErrorMessage,
reject_reason as RejectReason,
sap_preview as SapPreview,
case status
when 'PENDING' then cast( 'X' as abap_boolean )
else cast( ' ' as abap_boolean )
end as IsApprovable,
case status
when 'FAILED' then cast( 'X' as abap_boolean )
else cast( ' ' as abap_boolean )
end as IsRetriable,
@Semantics.user.createdBy: true
created_by as CreatedBy,
@Semantics.systemDateTime.createdAt: true
created_at as CreatedAt,
@Semantics.user.lastChangedBy: true
last_changed_by as LastChangedBy,
@Semantics.systemDateTime.lastChangedAt: true
last_changed_at as LastChangedAt,
@Semantics.systemDateTime.localInstanceLastChangedAt: true
local_last_changed_at as LocalLastChangedAt
}
@@ -0,0 +1,13 @@
/* TARGET OBJECT : service definition ZZUI_ARENA_CHANGE_APPRVL000_O4 (generated 2026-09-15)
abap: URI : abap:/repotree-v1/0M7DEJI/System%20Library/ZLOCAL/ZARENA_PLM/Business%20Services/Service%20Definitions/ZZUI_ARENA_CHANGE_APPRVL000_O4/zzui_arena_change_apprvl000_o4.srvd.acds
ACTION : replace entire content, then activate (after ZZC_CHANGEAPPROVAL000 view + BDEF).
expose ... as <alias>: the alias becomes the entity set (ChangeApproval) and the entity type (ChangeApproval_Type)
[source: abap-docs /abap-fiori-showcase/06_object_page_content (Service Definition), verified 2026-09-15].
provider contracts odata_v4_ui switches on the UI-binding syntax checks and must match the binding type
[source: abap-docs /abap-docs-cloud/ABENSRVD_PROVIDER_CONTRACT, verified 2026-09-15]. */
@EndUserText.label: 'Arena change approval - UI service'
define service ZZUI_ARENA_CHANGE_APPRVL000_O4
provider contracts odata_v4_ui
{
expose ZZC_CHANGEAPPROVAL000 as ChangeApproval;
}
+172
View File
@@ -0,0 +1,172 @@
# Predecessor flow — how the Express implementation actually works
Extracted 2026-09-10 by a full read of `~/projects/sap-apps/arena-plm`
(reference-only). File:line cites refer to that repo, mostly
`backend/src/arena-plm.js`. This record informs the rebuild's design; the
durable behavioral tombstones harvested from it live in `KNOWLEDGE.md`, and
the requirements it produced are in `PRD.md` (ARN-010, sharpened ARN-002/004/006).
**Shape of the system:** not event-driven — an **operator-driven staging
workbench**. Arena is the read-only source, SAP is the write target *and* the
durable ledger. The webhook and poller exist but are effectively inert (§1).
Real syncs are triggered by a human clicking Approve in the UI5 worklist.
## 1. Ingestion — three paths, only one live
**Webhook (`POST /api/arena/webhook`, routes/arena-webhook.js)** — mounted
before the XSUAA gate, Basic auth (timing-safe), raw-text body of any
content-type (delivery shape never pinned down), always answers 200 so Arena
never retries. `WEBHOOK_CAPTURE_ONLY` logs+acks; otherwise
`pollOutboundIntegration({dryRun})` with dryRun defaulting true. **The
consumer never syncs**: `arena-plm.js:1252-1256` is a literal TODO — non-dry
runs only mark event items reconciled in Arena. The webhook path writes
nothing to SAP.
**Outbound-integration pull queue (`arena-plm.js:1197-1263`)** — Arena holds
the cursor: `GET /outboundintegrations/{guid}/events?itemsReconciled=false`,
per-event `…/items`, then `PUT …/items/{itemGuid} {reconciled:true}`
(idempotent). No local state.
**Change poller (`routes/poll.js` → `arena-plm.js:1117-1191`)** — manual only
(no scheduler exists in the repo). Watermark precedence: explicit `since` →
newest `A_ChangeMaster.ChangeNumberValidFromDate` where
`startswith(ReasonForChangeText,'ARENA ')` → `defaults.arenaChangeCutoff`;
refuses non-dry runs with neither (`POLL/NO_CUTOFF`). Pages the whole Arena
`/changes` feed client-side (no working server-side date filter), filters
`effectiveDateTime > since`. Dedup via one bulk read of every `ARENA `-marked
change master out of SAP into a Map. Real runs create change masters only.
## 2. Staging & approval
SQLite via `node:sqlite` on the **ephemeral CF filesystem** (`db.js`):
`approvals(id, item_number, change_type PRODUCT|CHANGE_MASTER|BOM|DOCUMENTS,
status PENDING|APPROVED|REJECTED, arena_data, sap_preview, sap_result, error,
reject_reason, …)` — one row per (item × change type); queue lost on restart.
- Seeding (`POST /api/approvals/seed`): `previewFromArena()` per item — all
four syncs dry-run under `Promise.allSettled` (concurrency 6). **No dedup**
on (item, changeType): re-seeding duplicates rows.
- Approve: `bulk-approve` returns immediately; work runs in a detached async
IIFE guarded by a **module-level in-memory `postJob` singleton** (progress
source AND concurrency guard; forces `instances: 1`; on restart, in-flight
jobs leave rows PENDING forever). `chain-approve` is synchronous per item,
halts on first failure. Reject is a pure DB flip.
- **On approval only `item_number` is passed** — the stored `sap_preview` is
never replayed; every sync re-fetches Arena and recomputes. What was
approved is not necessarily what gets written (→ rebuild ARN-010).
- **`REJECTED` is overloaded**: human rejection AND SAP write failure land in
the same status, distinguishable only by `error` vs `reject_reason`; no
retry path from REJECTED except re-seeding (→ rebuild ARN-002 states).
## 3. Sync execution — the SAP write chain
Order from `config/sync-chain-order.json`: PRODUCT → CHANGE_MASTER → BOM →
DOCUMENTS (CM's material object record needs the material; BOM's
`EngineeringChangeDocForEdit` needs the CM). Services as in PRD interface
contracts (V4 product, V2 product fallback, BOM write `;v=0002` / read v1,
change master `;v=0002`, CV attachment).
**PRODUCT (`:1336-1663`)** — exists-check (V4 GET, 404 ⇒ create path).
Create: `ProductType` strictly from Arena attr "SAP Product Type" (missing ⇒
`422 SYNC/NO_PRODUCT_TYPE` — deliberately "not a material"); UOM via config +
hardcoded ISO map; plants from attr "SAP Plant" through `plantMapping`; weight
g→×1000, dims mm→×1000; description truncated 40. Deep insert with
`_ProductDescription`, `_ProductPlant` only when no `AvailabilityCheckType`;
else per-plant V2 `A_ProductPlant` POSTs; `ProcurementType` requires `MRPType`
in the same `ProductPlantSupplyPlanning` PATCH. Best-effort CM link keyed on
`startswith(ChangeNumberDescription,'ECO:')` — a different key than everything
else (landmine). Update path: `ProductType`/`BaseUnit` immutable;
`ProductDescription` PATCH by full key (nav PATCH → 501), POST fallback on
404/405.
**CHANGE_MASTER (`:1794-1873`)** — EFFECTIVE revision with `change.number`
via `/revisions` (`/items/{guid}/changes` 404s — does not exist).
Double-condition dedup: `ReasonForChangeText eq 'ARENA <eco>'` AND a material
object record for *this* item. Create: `A_ChangeMaster` with
`ChangeNumberDescription: "ECO: title"[0..40]`, `/Date(ms)/` valid-from,
`ReasonForChangeText: "ARENA <eco>"`; then object-type assignments Material +
MaterialBOM (`IsActiveForChangeNumber: true` mandatory — else 201 +
silently discarded), then the material object record. Errors returned, not
thrown.
**BOM (`:1665-1792`)** — first mapped plant wins; lines `quantity > 0`, item
numbers (i+1)*10 zero-padded 4; category via `bomlineByCategory` default 'L'.
Opportunistic ECM lookup (bare catch — silently drops ECM and creates the BOM
plain if the object-type assignment fails: traceability quietly lost).
Component pre-check: if all components missing in SAP → early return, no
orphan header (SAP's real error is an opaque consistency-check message).
Header POST with `EngineeringChangeDocForEdit` (never
`EngineeringChangeDocument` — creatable=false, silently dropped), validity end
`/Date(253402214400000)/`. Items POSTed serially; each item re-reads the
header on the v1 read service; ECM BOMs must carry `HeaderChangeDocument`.
`BOMItemIsSalesRelevant:'X'` hardcoded. Partial failure is a return value.
**Not idempotent — re-approval creates a second BOM header.**
**DOCUMENTS (`:1875-1913`)** — URL links only, no bytes: filters files to
`storageMethodName === 'WEB' && file.location` (blob-stored files silently
skipped), then V2 function import `CreateUrlAsAttachment` with hardcoded
`BusinessObjectTypeName BUS1001006`, `MIMEType 'text/url'`, description
"<item> <category> Rev <edition>"[0..40]. The editable
`arena-doc-category-to-sap-doctype.json` config is **ignored by code**.
**Not idempotent — re-approval duplicates attachments.**
**Cross-cutting:** CSRF token+cookie cached per service, invalidated only on
403 with exactly one retry; PRODUCT services reject bare-root token fetch
(seed from `Product?$top=1` / `A_Product?$top=1`). Arena 401 → re-login, one
retry. No backoff, no retry queue, no transactions. Idempotency exists only
for CHANGE_MASTER (double-check) and weakly for PRODUCT (exists→PATCH).
## 4. Arena reads
Session `POST /login {email, password, workspaceId}` → `arena_session_id`
header, cached 24 min vs 25-min expiry, single-flight promise lock, one 401
retry. Endpoints used by sync: `/items?number=` (resolves EFFECTIVE),
`/items/{guid}`, `/items/{guid}/revisions`, `/items/{guid}/bom`,
`/items/{guid}/files`, `/changes/{guid}`, `/changes?limit&offset`,
`/changes/{guid}/items`, `/settings/items/categories`, outbound-integration
trio. Write-back (`POST /changes/statuschanges`) is parked behind
`ARENA_WRITEBACK_ENABLED`.
**Load-bearing subtlety — WORKING-revision overlay (`:508-552`):** the seven
SAP classification attributes (SAP Object Type / Product Type / Product
Category / Sub-Category / Product Family / Business Unit / Plant) are only
writable on the WORKING revision (Arena error 3224), while `/items?number=`
returns the EFFECTIVE revision — so those seven are merged from WORKING over
EFFECTIVE. The overlay is best-effort: a transient Arena failure silently
loses "SAP Product Type" and turns a valid sync into `NO_PRODUCT_TYPE`.
## 5. Config actually read vs dead
Live (read by sync code, hot-reloaded, git-backed via external
`sap-apps-config` repo): `defaults.json`, `plant-mapping.json`,
`uom-arena-to-sap.json`, `material-group-by-product-type.json`,
`availability-check-by-product-type.json`, `lifecycle-to-xplant-status.json`,
`bomline-category-by-arena-category.json`, `sync-chain-order.json`.
Dead (loaded, zero read sites — confirms the KNOWLEDGE.md landmine):
`arena-category-to-sap.json`, `procurement-type.json` (code uses a hardcoded
map keyed on the first char of Arena "Item Type"),
`catalog-to-planning-strategy.json`. Ignored-but-editable:
`arena-doc-category-to-sap-doctype.json`. Hardcoded despite the "no
hardcoding" comment: ISO UOM map, status map, `ARENA ` marker, procurement
map, service paths, working-attrs list, BOM validity end, sales-relevant
flag, Language 'EN', 40-char truncations, BUS1001006.
## 6. Fragilities the rebuild must not inherit
1. In-memory `postJob` singleton = progress + concurrency guard; restart
orphans PENDING rows; forces `instances: 1`.
2. Fire-and-forget approval worker; no resume, no cancellation.
3. Seed dedup absent; BOM and DOCUMENTS syncs non-idempotent.
4. `REJECTED` overload (human vs SAP failure).
5. Silent ECM degradation (bare catches around ECO lookup, object-type
assignment, revision overlay).
6. Two disagreeing CM lookup keys (`ChangeNumberDescription` startswith vs
`ReasonForChangeText` eq; 40-char truncation can split them).
7. The `ARENA <eco>` free-text marker is the entire ledger — dedup key,
watermark, and status all hang on an editable 40-char field.
8. O(history) paging on every poll (Arena full feed; up to 100k SAP change
masters).
9. Arena credentials base64 in SQLite on the ephemeral filesystem; the whole
approvals queue dies on restart/redeploy.
10. Approved preview is decorative — execution re-fetches and recomputes.