diff --git a/.github/workflows/publish-preview.yml b/.github/workflows/publish-preview.yml
index ad3a049d3..e2828d73e 100644
--- a/.github/workflows/publish-preview.yml
+++ b/.github/workflows/publish-preview.yml
@@ -1,12 +1,15 @@
name: Publish preview packages
# Publishes a throwaway PREVIEW (prerelease) of ALL publishable packages, in
-# lockstep, only when the feature branch has an open PR — so the
-# published-package path can be tested without cutting a real `latest` release.
+# lockstep, on every push to a feature branch — so the published-package path
+# can be tested without cutting a real `latest` release.
#
# See docs/plans/2026-07-14-preview-package-publishing.md (in etendo_schema_forge).
#
-# - Trigger: push to feature/** (auto) + manual workflow_dispatch, gated by PR.
+# - Trigger: push to feature/** (auto) + manual workflow_dispatch. NOT gated
+# by an open PR: the consumer is `make bump-core-version`, which needs only
+# the published version, and a branch is often worth previewing before it is
+# ready for review. An open PR only adds the sticky comment.
# - Version: -preview.... (D3)
# - dist-tag: alpha — never `latest`, so consumers are unaffected. (D2)
# - Scope: the 6 publishable packages, lockstep. (D6)
@@ -31,8 +34,10 @@ concurrency:
cancel-in-progress: true
jobs:
+ # Looks up the branch's open PR, if any. This does NOT gate publishing — its
+ # only consumer is the sticky-comment step, which needs somewhere to post.
find-pr:
- name: Check for an open feature PR
+ name: Look up the branch's PR (optional)
runs-on: ubuntu-latest
outputs:
number: ${{ steps.pr.outputs.number }}
@@ -47,14 +52,15 @@ jobs:
--json number --jq '.[0].number // empty')
echo "number=$PR" >> "$GITHUB_OUTPUT"
if [ -z "$PR" ]; then
- echo "No open PR for $BRANCH — preview publication is skipped."
+ echo "No open PR for $BRANCH — publishing anyway, without a PR comment."
+ echo "Read the version off the 'preview-package' commit status or this run's log."
else
echo "Open PR #$PR found for $BRANCH."
fi
preview:
+ # `needs` only to read the PR number for the comment step — never to gate.
needs: find-pr
- if: needs.find-pr.outputs.number != ''
name: Build, test & publish preview
runs-on: ubuntu-latest
steps:
@@ -132,13 +138,14 @@ jobs:
# --- Make the published version "visible fácil" -----------------------
# A) Commit status: pins "alpha: " to this SHA, so it shows as a
# check on the commit AND in the PR's checks list — no need to open the
- # run to see what was published. Links back to this run.
- # B) PR comment: post a single fresh comment on the PR that gated publishing.
+ # run to see what was published. Links back to this run. This is the
+ # ONLY surface that always exists, so it carries the no-PR case.
+ # B) PR comment: when the branch has an open PR, post a single fresh comment
# with the version and the exact pin snippet. On each push the previous
# bot comment (matched by marker) is deleted and a NEW one is posted, so
# it always lands at the bottom of the PR and notifies subscribers
- # (editing in place would leave it buried and silent). Skipped silently
- # when there is no open PR (the find-pr job skips this entire job).
+ # (editing in place would leave it buried and silent). Skipped when there
+ # is no PR yet — publishing still happened; read the version off (A).
# Both are fail-soft: a comment/status hiccup must never fail the publish.
- name: Pin published version to the commit (status)
continue-on-error: true
@@ -153,6 +160,7 @@ jobs:
-f target_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
- name: Comment published version on the open PR (sticky)
+ if: needs.find-pr.outputs.number != ''
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/cli/src/lib/entity-methods.js b/cli/src/lib/entity-methods.js
index 1230eea63..9136925f3 100644
--- a/cli/src/lib/entity-methods.js
+++ b/cli/src/lib/entity-methods.js
@@ -269,6 +269,50 @@ export function applyMethodsToCrudPrediction(crud, methods, opts = {}) {
return crud;
}
+/**
+ * Is this AD tab's entity absent from the contract — i.e. did a human write
+ * `entities..exclude: true` in `decisions.json`?
+ *
+ * ETP-4793 — `exclude: true` had exactly one consumer, `resolve-curated.js`
+ * (`if (entityDecision.exclude === true) continue;`), so its whole effect was
+ * "absent from `contract.json`". Neither write path acted on it:
+ * `populateWindowSpec` walks `AD_Tab`, not the contract, so the entity still got
+ * an `ETGO_SF_ENTITY` row plus one `ETGO_SF_FIELD` row per AD column at the
+ * populate-step default `ISINCLUDED = 'Y'`, and nothing downstream closed them —
+ * `stepExcludeNonContractFields` compares column names against a FLAT set
+ * gathered across all contract entities, so an excluded entity's column survives
+ * whenever a contract entity happens to have a column of the same name.
+ *
+ * The measured consequence on the reference instance: 102 entities marked
+ * `exclude: true` across 28 specs, 90 of them present in `ETGO_SF_ENTITY`, 386
+ * of their field rows still `ISINCLUDED = 'Y'` — and one of them (`taxZone`)
+ * served with MORE verbs than the curated `tax` entity beside it, because a tab
+ * with no contract entity falls through to the window-level default while the
+ * curated entity is restricted by its own contract.
+ *
+ * Both write paths now consult this predicate and write `ISINCLUDED = 'N'` on
+ * the entity row, which is the lever every reader already honours: 24 call sites
+ * across the REST and MCP surfaces filter on `SFEntity.PROPERTY_ISINCLUDED`
+ * before touching an entity (`findIncludedEntity`, `listIncludedEntities`,
+ * `NeoServlet`, `NeoDiscoveryHelper`, …), and entity resolution always precedes
+ * field access. No new column, and no AD records, were needed.
+ *
+ * The empty-set guard is load-bearing: a contract with no `backendContract`
+ * entities at all (an older or partially-generated artifact) must answer `false`
+ * for every entity, or a single malformed contract would close an entire window.
+ *
+ * @param {object|null|undefined} contract - parsed contract.json
+ * @param {string} entityName - the NAME written to the ETGO_SF_ENTITY row
+ * @returns {boolean} true when the entity must be closed on both surfaces
+ */
+export function isEntityExcludedFromContract(contract, entityName) {
+ const entities = contract?.backendContract?.entities;
+ if (!entities || !entityName) return false;
+ const names = Object.keys(entities);
+ if (names.length === 0) return false;
+ return !names.includes(entityName);
+}
+
/**
* Read the resolved method list for an entity back off a generated contract.
* This is the ONLY function the two write paths (`push-to-neo.js` and
@@ -278,7 +322,11 @@ export function applyMethodsToCrudPrediction(crud, methods, opts = {}) {
* 1. `apiPrediction.crud..methods` — the resolved allowlist
* 2. `apiPrediction.window.readOnly` — window-level default, which also covers
* AD tabs that have no contract entity at all (excluded entities still get
- * an ETGO_SF_ENTITY row from `populateWindowSpec`)
+ * an ETGO_SF_ENTITY row from `populateWindowSpec`). Those rows now carry
+ * `ISINCLUDED = 'N'` — see `isEntityExcludedFromContract()` — so the method
+ * flags resolved here are unreachable for them, and are deliberately left
+ * at the window default rather than zeroed: an all-`N` set would break the
+ * GET/GETBYID invariant this file enforces.
* 3. all six methods — pre-ETP-4254 behaviour, unchanged
*
* ETP-4745 — `crud..delete === false` (set by `window.hideDelete` /
diff --git a/cli/src/lib/field-visibility.js b/cli/src/lib/field-visibility.js
new file mode 100644
index 000000000..9ad79b7dc
--- /dev/null
+++ b/cli/src/lib/field-visibility.js
@@ -0,0 +1,96 @@
+/**
+ * field-visibility.js — The single source of truth for the curated-visibility →
+ * NEO-flag projection.
+ *
+ * `ETGO_SF_FIELD` stores the same decision twice, on purpose:
+ * - `ISINCLUDED` / `ISREADONLY` — the two booleans NEO's runtime enforces.
+ * - `VISIBILITY` — the curated value verbatim, which `neo_schema`
+ * hands to agents (`system` and `readOnly` both
+ * collapse to Y/Y, so the flags cannot recover it).
+ *
+ * Storing a decision twice means it can disagree with itself. ETP-4793 / IMP-26
+ * found exactly that: `populateSpec` writes the two flags unconditionally but
+ * `VISIBILITY` only on some paths, so rows accumulate open flags with no
+ * curated value. `visibilityMatchesFlags()` is the predicate that detects it,
+ * and validator rule F23 is what runs it.
+ *
+ * Before ETP-4793 this function existed twice — exported from `push-to-neo.js`
+ * and inlined into `lib/neo-delta.js` to dodge a circular import. Both now
+ * import it from here. Do not add a third copy; a validator that re-implements
+ * the projection cannot detect a drift in the projection.
+ */
+
+/**
+ * Map a curated field visibility value to the NEO flag pair.
+ *
+ * Any value outside the curated set — including `null`, `undefined` and the
+ * empty string — is treated as `discarded` (closed). That default is what makes
+ * an absent `VISIBILITY` column coherent with `N`/`N` rather than a violation.
+ *
+ * @param {string|null|undefined} visibility - `editable` | `readOnly` | `system` | `discarded`
+ * @returns {{ isIncluded: 'Y'|'N', isReadOnly: 'Y'|'N' }}
+ */
+export function mapVisibility(visibility) {
+ switch (visibility) {
+ case 'editable':
+ return { isIncluded: 'Y', isReadOnly: 'N' };
+ case 'readOnly':
+ return { isIncluded: 'Y', isReadOnly: 'Y' };
+ case 'system':
+ return { isIncluded: 'Y', isReadOnly: 'Y' };
+ case 'discarded':
+ return { isIncluded: 'N', isReadOnly: 'N' };
+ default:
+ return { isIncluded: 'N', isReadOnly: 'N' };
+ }
+}
+
+/**
+ * The four curated visibility values, in the order the docs present them.
+ */
+export const CURATED_VISIBILITIES = Object.freeze(['editable', 'readOnly', 'system', 'discarded']);
+
+/**
+ * True when `visibility` is one of the four curated values. An absent or empty
+ * value is NOT curated — it is the "never written" state F23 reports separately
+ * from a genuine contradiction.
+ *
+ * @param {string|null|undefined} visibility
+ * @returns {boolean}
+ */
+export function isCuratedVisibility(visibility) {
+ return CURATED_VISIBILITIES.includes(visibility);
+}
+
+/**
+ * Check one stored row against the projection.
+ *
+ * Returns a verdict rather than a boolean because the two failure modes need
+ * different treatment (see F23 in docs/pipeline-validator-reference.md):
+ *
+ * - `contradiction` — `VISIBILITY` holds a curated value whose projection
+ * disagrees with the stored flags. Only a writer bug or a hand-edit can
+ * produce this, so it blocks.
+ * - `unwritten` — `VISIBILITY` is absent/empty while the flags say the field
+ * is included. Coherent for a closed field (`N`/`N` IS the default
+ * projection), a backfill gap for an open one, so it warns.
+ *
+ * @param {{ visibility?: string|null, isIncluded?: string, isReadOnly?: string }} row
+ * @returns {{ ok: boolean, kind: 'ok'|'contradiction'|'unwritten',
+ * expected: { isIncluded: string, isReadOnly: string } }}
+ */
+export function visibilityMatchesFlags(row) {
+ const visibility = row?.visibility ?? null;
+ // Absent flags default to the closed pair, matching mapVisibility's default —
+ // a row that omits both columns is coherent with an omitted VISIBILITY.
+ const isIncluded = row?.isIncluded ?? 'N';
+ const isReadOnly = row?.isReadOnly ?? 'N';
+ const expected = mapVisibility(visibility);
+ const ok = expected.isIncluded === isIncluded && expected.isReadOnly === isReadOnly;
+ if (ok) return { ok: true, kind: 'ok', expected };
+ return {
+ ok: false,
+ kind: isCuratedVisibility(visibility) ? 'contradiction' : 'unwritten',
+ expected,
+ };
+}
diff --git a/cli/src/lib/neo-delta.js b/cli/src/lib/neo-delta.js
index f4ecc1fc5..d420bc1e5 100644
--- a/cli/src/lib/neo-delta.js
+++ b/cli/src/lib/neo-delta.js
@@ -39,23 +39,16 @@
import { newEtendoId } from './etendo-uuid.js';
import { indexByNaturalKey } from './etgo-xml-parser.js';
-import { methodsToXmlFlags, resolveContractEntityMethods } from './entity-methods.js';
-
-/**
- * Local copy of mapVisibility() from push-to-neo.js. Inlined to keep this
- * module free of circular imports (push-to-neo imports computeWindowDelta).
- * If you change one, mirror the change in the other — both are intentionally
- * tiny so divergence is easy to spot.
- */
-function mapVisibility(visibility) {
- switch (visibility) {
- case 'editable': return { isIncluded: 'Y', isReadOnly: 'N' };
- case 'readOnly': return { isIncluded: 'Y', isReadOnly: 'Y' };
- case 'system': return { isIncluded: 'Y', isReadOnly: 'Y' };
- case 'discarded': return { isIncluded: 'N', isReadOnly: 'N' };
- default: return { isIncluded: 'N', isReadOnly: 'N' };
- }
-}
+import {
+ isEntityExcludedFromContract,
+ methodsToXmlFlags,
+ resolveContractEntityMethods,
+} from './entity-methods.js';
+// ETP-4793 — was an inlined copy (to dodge the push-to-neo → neo-delta import
+// cycle). Now a sibling in lib/, so there is no cycle and no second copy: the
+// live push, this offline projection and validator rule F23 all read the same
+// function.
+import { mapVisibility } from './field-visibility.js';
function normalizeAgentPrompt(value) {
if (value == null) return null;
@@ -65,8 +58,10 @@ function normalizeAgentPrompt(value) {
/**
* Local copy of normalizePreconditions() from push-to-neo.js. Inlined for the
- * same no-circular-import reason as mapVisibility/normalizeAgentPrompt — mirror
- * any change in the other. An explicit but empty declaration collapses to null
+ * same no-circular-import reason as normalizeAgentPrompt — mirror any change in
+ * the other. (mapVisibility used to be on this list; ETP-4793 moved it to
+ * lib/field-visibility.js instead, which is the better fix when a helper is
+ * needed by a third caller.) An explicit but empty declaration collapses to null
* so a stale DB value gets cleared (ETP-4275).
*/
function normalizePreconditions(value) {
@@ -249,6 +244,10 @@ export function computeWindowDelta(args) {
// Map from naturalKey → spec/entity/field UUID (for deterministic FKs).
const entityIdByNK = new Map();
+ // ETP-4793 — entity UUIDs whose contract entity is absent (`exclude: true`).
+ // Their field rows are closed too, mirroring `populateWindowSpec`.
+ const closedEntityIds = new Set();
+
let entitySeq = 0;
for (const tab of adTabs) {
entitySeq++;
@@ -273,6 +272,12 @@ export function computeWindowDelta(args) {
|| desiredEntityByTabName.get(tab.name)
|| tab.name;
+ // ETP-4793 — mirror the live push: a tab the contract does not declare
+ // (`exclude: true`) is closed on the entity row. The live counterpart is
+ // `buildEntityMethodFlagsResolver` → `upsertEntity({ isIncluded: 'N' })`.
+ const entityExcluded = isEntityExcludedFromContract(contract, entityName);
+ if (entityExcluded) closedEntityIds.add(entityId);
+
const entityRow = {
_naturalKey: entityNK,
ETGO_SF_ENTITY_ID: entityId,
@@ -281,7 +286,7 @@ export function computeWindowDelta(args) {
AD_MODULE_ID: moduleId,
NAME: entityName,
SEQNO: String(entitySeq * 10),
- ISINCLUDED: 'Y',
+ ISINCLUDED: entityExcluded ? 'N' : 'Y',
// ETP-4254 — the CRUD flags are NO LONGER hardcoded to 'Y'. push-to-neo's
// stepPopulateSpec hands populateWindowSpec a per-tab resolver built from
// the SAME contract value read here (see buildEntityMethodFlagsResolver),
@@ -323,11 +328,20 @@ export function computeWindowDelta(args) {
entityIdByNK,
prevFieldByNatural,
fieldUpserts,
+ closedEntityIds,
});
// ---- Mirror live-push behaviour: never create NEW records with ISINCLUDED=N
// stepExcludeNonContractFields only updates EXISTING rows; it never inserts
// a fresh ETGO_SF_FIELD record for a field that was never in the DB.
+ //
+ // ETP-4793 — this also prunes the field rows of a NEWLY closed entity, while
+ // the live `populateWindowSpec` does insert them with ISINCLUDED='N'. That gap
+ // is pre-existing and unchanged in kind (it already applied to every
+ // non-contract field of a brand-new window); on the reference instance all 90
+ // closed entities already have their rows in the prev snapshot, so they are
+ // kept and flipped. Widening the rule was deliberately left out of this change
+ // rather than folded in unmeasured.
const prunedFieldUpserts = fieldUpserts.filter(
f => prevFieldByNatural.has(f._naturalKey) || f.ISINCLUDED === 'Y',
);
@@ -563,6 +577,7 @@ function applyContractVisibilityToFields({
entityIdByNK,
prevFieldByNatural,
fieldUpserts,
+ closedEntityIds = new Set(),
}) {
const contractFields = extractFieldsFromContract(contract.backendContract);
const fieldDefaultExprs = buildFieldDefaultExprMap(decisions);
@@ -595,6 +610,15 @@ function applyContractVisibilityToFields({
}
for (const row of fieldUpserts) {
+ // ETP-4793 — a field of an entity the contract does not declare is closed
+ // outright, before the flat-column-set rule below gets a say. That rule is
+ // entity-blind on purpose (it mirrors `stepExcludeNonContractFields`
+ // bug-for-bug), which is exactly why an excluded entity's `Name` column used
+ // to survive: some OTHER entity's contract has a column called `Name`.
+ if (closedEntityIds.has(row.ETGO_SF_ENTITY_ID)) {
+ row.ISINCLUDED = 'N';
+ continue;
+ }
const colHit = contractFieldByEntityAndColumn.get(
`${row.ETGO_SF_ENTITY_ID}/${String(row.AD_COLUMN_ID)}`,
);
diff --git a/cli/src/neo-writer.js b/cli/src/neo-writer.js
index 9e53e58f3..fbb91e487 100644
--- a/cli/src/neo-writer.js
+++ b/cli/src/neo-writer.js
@@ -32,6 +32,39 @@ const SYSTEM_COLUMNS = [
'updatedby',
];
+/**
+ * The four field visibility values of the curated schema, as stored in
+ * ETGO_SF_FIELD.VISIBILITY (VARCHAR(20)) and read back by the MCP layer
+ * (McpSchemaFieldBuilder) to emit `visibility` / `userRequired` on neo_schema.
+ *
+ * Kept as data rather than validated inline so the set has exactly one
+ * definition on the writer side. NULL is also legal and means "not classified":
+ * populateSpec creates a row per AD column before any contract is applied, and
+ * columns the contract never mentions legitimately stay NULL.
+ */
+export const FIELD_VISIBILITIES = ['editable', 'readOnly', 'system', 'discarded'];
+
+/**
+ * Validate a visibility value on its way to the DB.
+ *
+ * Fails loudly on an unknown value instead of storing it: the MCP layer serves
+ * this column to agents as authoritative metadata, so a typo silently persisted
+ * here becomes a wrong instruction there.
+ *
+ * @param {string|null|undefined} visibility
+ * @returns {string|null} the value, or null when unclassified
+ */
+export function normalizeVisibility(visibility) {
+ if (visibility == null || visibility === '') return null;
+ if (!FIELD_VISIBILITIES.includes(visibility)) {
+ throw new Error(
+ `upsertField: invalid visibility "${visibility}" `
+ + `(expected one of ${FIELD_VISIBILITIES.join(', ')}, or null)`,
+ );
+ }
+ return visibility;
+}
+
/**
* Generate an Etendo-compatible UUID (32-char uppercase hex, no dashes).
*/
@@ -243,6 +276,10 @@ export async function upsertEntity(client, params) {
* @param {string} [params.fieldId] - If provided, UPDATE instead of INSERT
* @param {string} [params.isIncluded='Y']
* @param {string} [params.isReadOnly='N']
+ * @param {string} [params.visibility] - Curated visibility (see FIELD_VISIBILITIES).
+ * Orthogonal to isIncluded/isReadOnly, which drive NEO runtime behaviour: this
+ * column is agent-facing metadata only, and preserves the distinction those two
+ * booleans collapse (`system` and `readOnly` share the same Y/Y pair).
* @param {string} [params.defaultValue]
* @param {string} [params.agentPrompt] - Per-field agent guidance for neo_schema
* @param {string} [params.javaQualifier]
@@ -300,6 +337,10 @@ export async function upsertField(client, params) {
setClauses.push(`isbusinesscritical = $${paramIndex++}`);
values.push(params.isBusinessCritical ?? 'N');
}
+ if ('visibility' in params) {
+ setClauses.push(`visibility = $${paramIndex++}`);
+ values.push(normalizeVisibility(params.visibility));
+ }
setClauses.push(`updated = $${paramIndex++}`);
values.push(auditVals.updated);
@@ -319,6 +360,7 @@ export async function upsertField(client, params) {
const javaQualifier = params.javaQualifier ?? null;
const seqNo = params.seqNo ?? null;
const agentPrompt = params.agentPrompt ?? null;
+ const visibility = normalizeVisibility(params.visibility);
const fieldId = generateId();
await client.query(
@@ -326,13 +368,13 @@ export async function upsertField(client, params) {
(etgo_sf_field_id, etgo_sf_entity_id, ad_column_id, ad_module_id,
isincluded, isreadonly, isbusinesscritical, defaultvalue, java_qualifier, seqno,
ad_client_id, ad_org_id, isactive, created, createdby, updated, updatedby,
- agent_prompt)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)`,
+ agent_prompt, visibility)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`,
[fieldId, entityId, columnId, moduleId,
isIncluded, isReadOnly, isBusinessCritical, defaultValue, javaQualifier, seqNo,
auditVals.ad_client_id, auditVals.ad_org_id, auditVals.isactive,
auditVals.created, auditVals.createdby, auditVals.updated, auditVals.updatedby,
- agentPrompt],
+ agentPrompt, visibility],
);
return { fieldId, created: true };
}
@@ -498,12 +540,18 @@ async function populateWindowSpec(client, { specId, windowId, moduleId, excludeS
// flag to 'N' when not supplied, so an entity must ALWAYS receive an explicit
// set: the old `includeAllMethods: false` path left entities with no read
// access, which is never a legitimate outcome.
+ //
+ // ETP-4793 — the resolver may also return `isIncluded: 'N'` for a tab the
+ // contract does not declare (`exclude: true`). The default resolver keeps
+ // `upsertEntity`'s own `'Y'` default, so callers that pass no resolver are
+ // unaffected.
const resolveMethodFlags = (tab) => (
methodFlagsFor ? methodFlagsFor(tab) : { ...ALL_METHOD_FLAGS }
);
let entityCount = 0;
let fieldCount = 0;
+ let closedEntityCount = 0;
const entities = [];
const changes = {
entities: { created: 0, updated: 0, deleted: 0 },
@@ -519,6 +567,17 @@ async function populateWindowSpec(client, { specId, windowId, moduleId, excludeS
|| existingEntityByName.get(tab.name)
|| null;
+ const entityFlags = resolveMethodFlags(tab);
+ // ETP-4793 — an entity the contract does not declare is closed, and so are
+ // its fields. Closing the entity alone would suffice for behaviour (every
+ // reader filters ETGO_SF_ENTITY.ISINCLUDED before it ever reaches a field),
+ // but leaving 15 field rows per closed entity claiming ISINCLUDED='Y' makes
+ // the data lie to anyone counting the agent surface — which is how the gap
+ // went unnoticed. `visibility` is deliberately NOT written here: the offline
+ // XML delta does not model that column at all (IMP-26 §4.2), and NULL next
+ // to 'N'/'N' is already the coherent pair under `mapVisibility`.
+ const entityIsClosed = entityFlags.isIncluded === 'N';
+ if (entityIsClosed) closedEntityCount++;
const { entityId, created: entityCreated } = await upsertEntity(client, {
specId,
tabId: tab.ad_tab_id,
@@ -526,7 +585,7 @@ async function populateWindowSpec(client, { specId, windowId, moduleId, excludeS
name: tab.name,
seqNo: entitySeqNo,
entityId: existingEntityId,
- ...resolveMethodFlags(tab),
+ ...entityFlags,
audit,
});
entityCount++;
@@ -576,6 +635,7 @@ async function populateWindowSpec(client, { specId, windowId, moduleId, excludeS
moduleId,
fieldId: existingFieldId,
seqNo: fieldSeqCounter * 10,
+ ...(entityIsClosed ? { isIncluded: 'N' } : {}),
audit,
});
fieldCount++;
@@ -591,7 +651,7 @@ async function populateWindowSpec(client, { specId, windowId, moduleId, excludeS
// Delete stale entities (exist in DB but tab no longer in AD)
await deleteStaleEntities(existingEntityByTab, visitedEntityIds, client, changes);
- return { entityCount, fieldCount, entities, changes };
+ return { entityCount, fieldCount, closedEntityCount, entities, changes };
}
async function deleteDuplicateEntities(existingEntityResult, entityId, client, changes) {
diff --git a/cli/src/push-to-neo.js b/cli/src/push-to-neo.js
index e583f0d6f..a41e98639 100755
--- a/cli/src/push-to-neo.js
+++ b/cli/src/push-to-neo.js
@@ -24,8 +24,14 @@ import {
import { computeWindowDelta, serializeDelta } from './lib/neo-delta.js';
import { resolveAgentPromptRefs } from './lib/agent-prompt-ref.js';
import { loadEtgoXmlSnapshot } from './lib/etgo-xml-parser.js';
+import { mapVisibility } from './lib/field-visibility.js';
import { GO_MODULE_ID } from './lib/constants.js';
-import { methodsToWriterFlags, NEO_HTTP_METHODS, resolveContractEntityMethods } from './lib/entity-methods.js';
+import {
+ isEntityExcludedFromContract,
+ methodsToWriterFlags,
+ NEO_HTTP_METHODS,
+ resolveContractEntityMethods,
+} from './lib/entity-methods.js';
import { isMainModule } from './utils.js';
const __filename = fileURLToPath(import.meta.url);
@@ -52,21 +58,12 @@ export function toSpecName(windowName) {
/**
* Map a field visibility value to NEO params.
* Returns { isIncluded: "Y"|"N", isReadOnly: "Y"|"N" }.
+ *
+ * ETP-4793 — the implementation moved to `lib/field-visibility.js` so the
+ * offline delta path and validator rule F23 share it instead of re-declaring
+ * it. Re-exported here because this name is part of the module's public API.
*/
-export function mapVisibility(visibility) {
- switch (visibility) {
- case 'editable':
- return { isIncluded: 'Y', isReadOnly: 'N' };
- case 'readOnly':
- return { isIncluded: 'Y', isReadOnly: 'Y' };
- case 'system':
- return { isIncluded: 'Y', isReadOnly: 'Y' };
- case 'discarded':
- return { isIncluded: 'N', isReadOnly: 'N' };
- default:
- return { isIncluded: 'N', isReadOnly: 'N' };
- }
-}
+export { mapVisibility };
/**
* Build the full webhook URL from a base Etendo URL and webhook name.
@@ -286,7 +283,9 @@ export async function pushToNeo(windowName, options = {}) {
const allFields = extractFieldsFromContract(contract.backendContract);
if (options.dryRun === true) {
- return reportDryRunPlan({ allFields, specName, windowId, windowDisplayName, windowName, contract });
+ return reportDryRunPlan({
+ allFields, specName, windowId, windowDisplayName, windowName, contract, schemaRawData,
+ });
}
return executePushTransaction({
@@ -440,6 +439,11 @@ export function buildFieldUpdateParams(f, ctx, fieldId, entityId) {
moduleId: ctx.moduleId,
isIncluded: vis.isIncluded,
isReadOnly: vis.isReadOnly,
+ // Stored alongside — not instead of — the two booleans above. mapVisibility
+ // collapses four curated values into two flags, which is what NEO's runtime
+ // needs but loses the distinction agents are told to act on (`system` and
+ // `readOnly` both map to Y/Y). neo_schema reads this column verbatim.
+ visibility: f.visibility ?? null,
isBusinessCritical: f.businessCritical ? 'Y' : 'N',
audit: ctx.auditOpts,
};
@@ -477,17 +481,47 @@ function summarizeRestrictedEntityMethods(contract) {
return restricted;
}
-function reportDryRunPlan({ allFields, specName, windowId, windowDisplayName, windowName, contract }) {
+/**
+ * ETP-4793 — the entities the push would close (`ISINCLUDED = 'N'`) because the
+ * contract does not declare them. Read off `schemaRawData`, not the contract,
+ * precisely because these entities are the ones the contract is missing.
+ *
+ * @param {object} contract - parsed contract.json
+ * @param {object} schemaRawData - parsed schema-raw.json
+ * @returns {string[]} curated entity names, sorted
+ */
+function summarizeExcludedEntities(contract, schemaRawData) {
+ const names = (schemaRawData?.entities ?? [])
+ .map((ent) => ent.name)
+ .filter((name) => isEntityExcludedFromContract(contract, name));
+ return [...new Set(names)].sort((left, right) => left.localeCompare(right));
+}
+
+function reportDryRunPlan({
+ allFields, specName, windowId, windowDisplayName, windowName, contract, schemaRawData,
+}) {
const entityMethods = summarizeRestrictedEntityMethods(contract);
+ const excludedEntities = summarizeExcludedEntities(contract, schemaRawData);
const plan = {
spec: { action: 'upsertSpec', params: { windowId, name: specName, specType: 'W' } },
- populate: { action: 'populateSpec', params: { specId: '(from step 1)' }, entityMethods },
+ populate: {
+ action: 'populateSpec',
+ params: { specId: '(from step 1)' },
+ entityMethods,
+ excludedEntities,
+ },
fields: allFields.map(f => {
const vis = mapVisibility(f.visibility);
return {
action: 'upsertField',
entityName: f.entityName,
- params: { entityId: '(from populate)', column: f.column, isIncluded: vis.isIncluded, isReadOnly: vis.isReadOnly },
+ params: {
+ entityId: '(from populate)',
+ column: f.column,
+ isIncluded: vis.isIncluded,
+ isReadOnly: vis.isReadOnly,
+ visibility: f.visibility ?? null,
+ },
};
}),
};
@@ -509,6 +543,12 @@ function reportDryRunPlan({ allFields, specName, windowId, windowDisplayName, wi
console.log(` ${name}: ${entityMethods[name].join(', ')}`);
}
}
+ if (excludedEntities.length === 0) {
+ console.log(` Entities closed (exclude: true): none`);
+ } else {
+ console.log(` Entities closed (exclude: true) — ISINCLUDED='N' on entity + fields:`);
+ console.log(` ${excludedEntities.join(', ')}`);
+ }
console.log(`\n Step 3: ${plan.fields.length} field updates via upsertField`);
const included = plan.fields.filter(f => f.params.isIncluded === 'Y');
@@ -526,6 +566,7 @@ function reportDryRunPlan({ allFields, specName, windowId, windowDisplayName, wi
included: included.length,
excluded: excluded.length,
readOnly: readOnly.length,
+ excludedEntities,
},
};
}
@@ -599,12 +640,19 @@ async function stepUpsertSpec(client, ctx) {
* pure function of (contract, NAME), and NAME is already asserted equal between
* the two paths by the XML regeneration check.
*
- * A tab with no contract entity (excluded entity, extra AD tab) falls through to
- * the window-level default inside `resolveContractEntityMethods` — i.e. still
- * read-only for a `window.readOnly` window, all methods otherwise.
+ * ETP-4793 — a tab with no contract entity (`exclude: true` in `decisions.json`,
+ * or an extra AD tab) now also gets `isIncluded: 'N'`, which is what actually
+ * closes it on both the REST and MCP surfaces; see
+ * `isEntityExcludedFromContract()` for why that column and not a new one. Its
+ * method flags still fall through to the window-level default, deliberately —
+ * they are unreachable once the entity is not included, and zeroing GET would
+ * break the invariant `entity-methods.js` enforces.
+ *
+ * `isIncluded` is `'Y'` for every entity the contract does declare, i.e. the
+ * value `upsertEntity` already defaults to, so nothing changes for them.
*
* @param {object} ctx - push context carrying `schemaRawData` + `contract`
- * @returns {(tab: {name: string}) => object} writer method flags
+ * @returns {(tab: {name: string}) => object} writer entity flags, `isIncluded` included
*/
export function buildEntityMethodFlagsResolver({ schemaRawData, contract }) {
const byTabName = new Map();
@@ -613,7 +661,10 @@ export function buildEntityMethodFlagsResolver({ schemaRawData, contract }) {
}
return (tab) => {
const entityName = byTabName.get(tab?.name) ?? tab?.name;
- return methodsToWriterFlags(resolveContractEntityMethods(contract, entityName));
+ return {
+ isIncluded: isEntityExcludedFromContract(contract, entityName) ? 'N' : 'Y',
+ ...methodsToWriterFlags(resolveContractEntityMethods(contract, entityName)),
+ };
};
}
diff --git a/cli/src/validate-pipeline.js b/cli/src/validate-pipeline.js
index 2857cd07f..3f718501c 100644
--- a/cli/src/validate-pipeline.js
+++ b/cli/src/validate-pipeline.js
@@ -30,6 +30,8 @@ import {
resolveEntityHideDelete,
resolveEntityMethods,
} from './lib/entity-methods.js';
+import { parseEtgoXmlFile } from './lib/etgo-xml-parser.js';
+import { visibilityMatchesFlags } from './lib/field-visibility.js';
const execFileAsync = promisify(execFile);
@@ -807,6 +809,170 @@ async function ruleF22(artifactDir, artifactName) {
'Remove window.customTabsAfterBottom, or remove tabOrder from the listed custom tab(s).');
}
+// ─── F23 — ETGO_SF_FIELD visibility vs the flags it projects to ─────────────
+// ETP-4793 / IMP-26 §5.3. `populateSpec` writes ISINCLUDED/ISREADONLY on every
+// push but VISIBILITY only on some paths, so the same decision — stored twice
+// on purpose, because neo_schema needs the curated word and the runtime needs
+// the booleans — can drift apart silently. This rule reads the exported
+// sourcedata and re-runs the projection over it.
+
+/**
+ * Resolve the com.etendoerp.go sourcedata directory holding the exported
+ * ETGO_SF_*.xml files. Mirrors `resolveDefaultPrevXmlDir` in push-to-neo.js.
+ *
+ * @param {string} root - schema_forge repo root
+ * @returns {string}
+ */
+function resolveGoSourcedataDir(root) {
+ if (process.env.SF_GO_SOURCEDATA_DIR) return process.env.SF_GO_SOURCEDATA_DIR;
+ const etendoRoot = process.env.ETENDO_ROOT || join(root, '..');
+ return join(etendoRoot, 'modules', 'com.etendoerp.go', 'src-db', 'database', 'sourcedata');
+}
+
+// One 8 MB XML parse per process, not per artifact. Keyed by directory so a
+// test pointing at a fixture never reads the cached production snapshot.
+const f23SnapshotCache = new Map();
+
+/**
+ * Parse the three sourcedata XMLs and index the field rows by spec name.
+ *
+ * Returns `null` — not an empty index — when the directory is absent, so the
+ * caller can tell "no .go checkout here" apart from "checkout present, spec has
+ * no rows".
+ *
+ * @param {string} sourcedataDir
+ * @returns {Promise