Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .changeset/lint-cbp-ambiguous-master.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
'@objectstack/lint': minor
---

security lint: report a `controlled_by_parent` object whose master is decided by FIELD DECLARATION ORDER (#14747)

`SecurityPlugin.resolveCbpRelation` resolves the master a `controlled_by_parent`
object derives record-level access from through three tiers — a required
`master_detail`, then any `master_detail`, then a required `lookup` — and picks
inside a tier with `Array.prototype.find`. So when two or more candidates sit in
the tier that wins, the master is whichever one the field map happens to list
first. Measured on a real kernel: an object declaring two required lookups
resolved its security master to the first-declared one, and swapping the two
field declarations — nothing else — repointed every row's record-level access
to the other object. Nothing reported it: not `os validate`, not `os lint`, not
a boot warning.

New error id **`security-controlled-by-parent-ambiguous-relation`**, the mirror
image of `security-controlled-by-parent-no-relation` (#7503): that one reports
ZERO candidates, this one reports two or more. The message names every
candidate — field, type and master — in declaration order, says which tier was
tested, and says which candidate wins today and therefore which object access
derives from right now.

Only the **winning** tier is judged, and that is not a shortcut: the runtime's
`??` chain stops at the first tier that resolves, so a tie in a lower tier is
masked by a higher tier's single winner and is not a decision the platform ever
makes. An object with one required `master_detail` and two required lookups is
silent, and stays silent.

`error` rather than advisory, for the inverse of the usual reason. The other
error rules in this linter mirror a hard runtime refusal; this one has none to
mirror precisely BECAUSE the runtime does not refuse — it silently picks — so
author time is the only place the ambiguity can ever surface. What it does meet
is the admissibility bar the #7503 rule states: a self-contained property of the
object document, no per-permission-set nuance to adjudicate, and no legitimate
reading, since two tied candidates is not an author saying which master they
meant.

This **narrows the accept set of a gating rule** — error findings fail
`os validate` / `os compile`. Measured over the shipped corpus: the three
`controlled_by_parent` objects in the example apps (`showcase_invoice_line`,
`showcase_expense_line`, `crm_opportunity_line_item`) plus the 27
`ObjectSchema.create` sites the `check:doc-security-posture` gate reads across
226 marked prose blocks — **0 findings before and 0 after**. Each of the three
declares exactly one required `master_detail`, so tier 1 wins with a single
candidate. `showcase_invoice_line` is the interesting one: it also carries a
required `lookup`, and the rule is silent because that tie-free lower tier is
never reached.

No runtime behaviour changes. `resolveCbpRelation` in this package now reads its
tiers from one shared table so the two rules cannot disagree about which tier
wins, and its answer is unchanged by construction: `find` over a tier is the
first element `filter` over that tier keeps. The mirror's one deliberate
divergence from the runtime is kept — `reference` is the only spelling accepted
here (#5017), so a field carrying the rejected `reference_to` alias is not a
candidate and cannot create a tie.
18 changes: 11 additions & 7 deletions packages/lint/scripts/check-doc-security-posture.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,10 +62,14 @@
* - `SECURITY_OWD_ALIAS` / `SECURITY_EXTERNAL_WIDER` fire only on values that
* are static strings (the rule itself requires `typeof === 'string'`; the
* sentinel is not a string).
* - `SECURITY_CBP_NO_RELATION` reads the `fields` subtree, so when that
* subtree is not fully static its findings are SUPPRESSED with a printed
* notice — a `Field.master_detail(...)` factory call must not read as "no
* relation". (No marked block declares `controlled_by_parent` today; the
* - `SECURITY_CBP_NO_RELATION` and `SECURITY_CBP_AMBIGUOUS_RELATION` read the
* `fields` subtree, so when that subtree is not fully static their findings
* are SUPPRESSED with a printed notice — a `Field.master_detail(...)` factory
* call must not read as "no relation", and it must not read as "absent from
* the master_detail tiers" either: an opaque field is invisible to every tier
* predicate, so a single real `master_detail` masked by a factory call would
* hand the win to a lower tier and report a tie the platform never resolves
* (#14747). (No marked block declares `controlled_by_parent` today; the
* suppression exists so the first one that does cannot false-red.)
*
* Two shapes are refused loudly rather than skipped, because a silent skip is
Expand DownExpand Up@@ -125,7 +129,7 @@ import { tmpdir } from 'node:os';

import { requireDefaultExport, requireDependency } from '../../../scripts/import-prerequisite.mjs';
const ts = await requireDefaultExport('typescript', () => import('typescript'), import.meta.url);
const { validateSecurityPosture, SECURITY_CBP_NO_RELATION } = await requireDependency('@objectstack/lint', () => import('@objectstack/lint'), import.meta.url);
const { validateSecurityPosture, SECURITY_CBP_NO_RELATION, SECURITY_CBP_AMBIGUOUS_RELATION } = await requireDependency('@objectstack/lint', () => import('@objectstack/lint'), import.meta.url);

const HERE = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(HERE, '../../..');
Expand DownExpand Up@@ -344,10 +348,10 @@ export function judgeFile(fileAbs, relPath, marker) {
const findings = validateSecurityPosture({ objects: [obj] }).filter((f) => f.severity === 'error');
const kept = [];
for (const f of findings) {
if (f.rule === SECURITY_CBP_NO_RELATION && !fieldsComplete) {
if ((f.rule === SECURITY_CBP_NO_RELATION || f.rule === SECURITY_CBP_AMBIGUOUS_RELATION) && !fieldsComplete) {
notices.push(
`${relPath}:${pageLine} object "${obj.name}": ${f.rule} suppressed — ` +
`the fields subtree is not statically evaluable (factory calls), so "no relation" would be a guess`,
`the fields subtree is not statically evaluable (factory calls), so the verdict would be a guess`,
);
continue;
}
Expand Down
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -297,6 +297,7 @@ export {
SECURITY_GRANT_EXPIRED_AT_AUTHORING,
SECURITY_DELEGATION_MISSING_REASON,
SECURITY_CBP_NO_RELATION,
SECURITY_CBP_AMBIGUOUS_RELATION,
} from './validate-security-posture.js';
export type { SecurityFinding, SecuritySeverity } from './validate-security-posture.js';

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .changeset/lint-cbp-ambiguous-master.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
'@objectstack/lint': minor
---

security lint: report a `controlled_by_parent` object whose master is decided by FIELD DECLARATION ORDER (#14747)

`SecurityPlugin.resolveCbpRelation` resolves the master a `controlled_by_parent`
object derives record-level access from through three tiers — a required
`master_detail`, then any `master_detail`, then a required `lookup` — and picks
inside a tier with `Array.prototype.find`. So when two or more candidates sit in
the tier that wins, the master is whichever one the field map happens to list
first. Measured on a real kernel: an object declaring two required lookups
resolved its security master to the first-declared one, and swapping the two
field declarations — nothing else — repointed every row's record-level access
to the other object. Nothing reported it: not `os validate`, not `os lint`, not
a boot warning.

New error id **`security-controlled-by-parent-ambiguous-relation`**, the mirror
image of `security-controlled-by-parent-no-relation` (#7503): that one reports
ZERO candidates, this one reports two or more. The message names every
candidate — field, type and master — in declaration order, says which tier was
tested, and says which candidate wins today and therefore which object access
derives from right now.

Only the **winning** tier is judged, and that is not a shortcut: the runtime's
`??` chain stops at the first tier that resolves, so a tie in a lower tier is
masked by a higher tier's single winner and is not a decision the platform ever
makes. An object with one required `master_detail` and two required lookups is
silent, and stays silent.

`error` rather than advisory, for the inverse of the usual reason. The other
error rules in this linter mirror a hard runtime refusal; this one has none to
mirror precisely BECAUSE the runtime does not refuse — it silently picks — so
author time is the only place the ambiguity can ever surface. What it does meet
is the admissibility bar the #7503 rule states: a self-contained property of the
object document, no per-permission-set nuance to adjudicate, and no legitimate
reading, since two tied candidates is not an author saying which master they
meant.

This **narrows the accept set of a gating rule** — error findings fail
`os validate` / `os compile`. Measured over the shipped corpus: the three
`controlled_by_parent` objects in the example apps (`showcase_invoice_line`,
`showcase_expense_line`, `crm_opportunity_line_item`) plus the 27
`ObjectSchema.create` sites the `check:doc-security-posture` gate reads across
226 marked prose blocks — **0 findings before and 0 after**. Each of the three
declares exactly one required `master_detail`, so tier 1 wins with a single
candidate. `showcase_invoice_line` is the interesting one: it also carries a
required `lookup`, and the rule is silent because that tie-free lower tier is
never reached.

No runtime behaviour changes. `resolveCbpRelation` in this package now reads its
tiers from one shared table so the two rules cannot disagree about which tier
wins, and its answer is unchanged by construction: `find` over a tier is the
first element `filter` over that tier keeps. The mirror's one deliberate
divergence from the runtime is kept — `reference` is the only spelling accepted
here (#5017), so a field carrying the rejected `reference_to` alias is not a
candidate and cannot create a tie.
18 changes: 11 additions & 7 deletions packages/lint/scripts/check-doc-security-posture.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,10 +62,14 @@
* - `SECURITY_OWD_ALIAS` / `SECURITY_EXTERNAL_WIDER` fire only on values that
* are static strings (the rule itself requires `typeof === 'string'`; the
* sentinel is not a string).
* - `SECURITY_CBP_NO_RELATION` reads the `fields` subtree, so when that
* subtree is not fully static its findings are SUPPRESSED with a printed
* notice — a `Field.master_detail(...)` factory call must not read as "no
* relation". (No marked block declares `controlled_by_parent` today; the
* - `SECURITY_CBP_NO_RELATION` and `SECURITY_CBP_AMBIGUOUS_RELATION` read the
* `fields` subtree, so when that subtree is not fully static their findings
* are SUPPRESSED with a printed notice — a `Field.master_detail(...)` factory
* call must not read as "no relation", and it must not read as "absent from
* the master_detail tiers" either: an opaque field is invisible to every tier
* predicate, so a single real `master_detail` masked by a factory call would
* hand the win to a lower tier and report a tie the platform never resolves
* (#14747). (No marked block declares `controlled_by_parent` today; the
* suppression exists so the first one that does cannot false-red.)
*
* Two shapes are refused loudly rather than skipped, because a silent skip is
Expand DownExpand Up@@ -125,7 +129,7 @@ import { tmpdir } from 'node:os';

import { requireDefaultExport, requireDependency } from '../../../scripts/import-prerequisite.mjs';
const ts = await requireDefaultExport('typescript', () => import('typescript'), import.meta.url);
const { validateSecurityPosture, SECURITY_CBP_NO_RELATION } = await requireDependency('@objectstack/lint', () => import('@objectstack/lint'), import.meta.url);
const { validateSecurityPosture, SECURITY_CBP_NO_RELATION, SECURITY_CBP_AMBIGUOUS_RELATION } = await requireDependency('@objectstack/lint', () => import('@objectstack/lint'), import.meta.url);

const HERE = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(HERE, '../../..');
Expand DownExpand Up@@ -344,10 +348,10 @@ export function judgeFile(fileAbs, relPath, marker) {
const findings = validateSecurityPosture({ objects: [obj] }).filter((f) => f.severity === 'error');
const kept = [];
for (const f of findings) {
if (f.rule === SECURITY_CBP_NO_RELATION && !fieldsComplete) {
if ((f.rule === SECURITY_CBP_NO_RELATION || f.rule === SECURITY_CBP_AMBIGUOUS_RELATION) && !fieldsComplete) {
notices.push(
`${relPath}:${pageLine} object "${obj.name}": ${f.rule} suppressed — ` +
`the fields subtree is not statically evaluable (factory calls), so "no relation" would be a guess`,
`the fields subtree is not statically evaluable (factory calls), so the verdict would be a guess`,
);
continue;
}
Expand Down
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -297,6 +297,7 @@ export {
SECURITY_GRANT_EXPIRED_AT_AUTHORING,
SECURITY_DELEGATION_MISSING_REASON,
SECURITY_CBP_NO_RELATION,
SECURITY_CBP_AMBIGUOUS_RELATION,
} from './validate-security-posture.js';
export type { SecurityFinding, SecuritySeverity } from './validate-security-posture.js';

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .changeset/lint-cbp-ambiguous-master.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
'@objectstack/lint': minor
---

security lint: report a `controlled_by_parent` object whose master is decided by FIELD DECLARATION ORDER (#14747)

`SecurityPlugin.resolveCbpRelation` resolves the master a `controlled_by_parent`
object derives record-level access from through three tiers — a required
`master_detail`, then any `master_detail`, then a required `lookup` — and picks
inside a tier with `Array.prototype.find`. So when two or more candidates sit in
the tier that wins, the master is whichever one the field map happens to list
first. Measured on a real kernel: an object declaring two required lookups
resolved its security master to the first-declared one, and swapping the two
field declarations — nothing else — repointed every row's record-level access
to the other object. Nothing reported it: not `os validate`, not `os lint`, not
a boot warning.

New error id **`security-controlled-by-parent-ambiguous-relation`**, the mirror
image of `security-controlled-by-parent-no-relation` (#7503): that one reports
ZERO candidates, this one reports two or more. The message names every
candidate — field, type and master — in declaration order, says which tier was
tested, and says which candidate wins today and therefore which object access
derives from right now.

Only the **winning** tier is judged, and that is not a shortcut: the runtime's
`??` chain stops at the first tier that resolves, so a tie in a lower tier is
masked by a higher tier's single winner and is not a decision the platform ever
makes. An object with one required `master_detail` and two required lookups is
silent, and stays silent.

`error` rather than advisory, for the inverse of the usual reason. The other
error rules in this linter mirror a hard runtime refusal; this one has none to
mirror precisely BECAUSE the runtime does not refuse — it silently picks — so
author time is the only place the ambiguity can ever surface. What it does meet
is the admissibility bar the #7503 rule states: a self-contained property of the
object document, no per-permission-set nuance to adjudicate, and no legitimate
reading, since two tied candidates is not an author saying which master they
meant.

This **narrows the accept set of a gating rule** — error findings fail
`os validate` / `os compile`. Measured over the shipped corpus: the three
`controlled_by_parent` objects in the example apps (`showcase_invoice_line`,
`showcase_expense_line`, `crm_opportunity_line_item`) plus the 27
`ObjectSchema.create` sites the `check:doc-security-posture` gate reads across
226 marked prose blocks — **0 findings before and 0 after**. Each of the three
declares exactly one required `master_detail`, so tier 1 wins with a single
candidate. `showcase_invoice_line` is the interesting one: it also carries a
required `lookup`, and the rule is silent because that tie-free lower tier is
never reached.

No runtime behaviour changes. `resolveCbpRelation` in this package now reads its
tiers from one shared table so the two rules cannot disagree about which tier
wins, and its answer is unchanged by construction: `find` over a tier is the
first element `filter` over that tier keeps. The mirror's one deliberate
divergence from the runtime is kept — `reference` is the only spelling accepted
here (#5017), so a field carrying the rejected `reference_to` alias is not a
candidate and cannot create a tie.
18 changes: 11 additions & 7 deletions packages/lint/scripts/check-doc-security-posture.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,10 +62,14 @@
* - `SECURITY_OWD_ALIAS` / `SECURITY_EXTERNAL_WIDER` fire only on values that
* are static strings (the rule itself requires `typeof === 'string'`; the
* sentinel is not a string).
* - `SECURITY_CBP_NO_RELATION` reads the `fields` subtree, so when that
* subtree is not fully static its findings are SUPPRESSED with a printed
* notice — a `Field.master_detail(...)` factory call must not read as "no
* relation". (No marked block declares `controlled_by_parent` today; the
* - `SECURITY_CBP_NO_RELATION` and `SECURITY_CBP_AMBIGUOUS_RELATION` read the
* `fields` subtree, so when that subtree is not fully static their findings
* are SUPPRESSED with a printed notice — a `Field.master_detail(...)` factory
* call must not read as "no relation", and it must not read as "absent from
* the master_detail tiers" either: an opaque field is invisible to every tier
* predicate, so a single real `master_detail` masked by a factory call would
* hand the win to a lower tier and report a tie the platform never resolves
* (#14747). (No marked block declares `controlled_by_parent` today; the
* suppression exists so the first one that does cannot false-red.)
*
* Two shapes are refused loudly rather than skipped, because a silent skip is
Expand DownExpand Up@@ -125,7 +129,7 @@ import { tmpdir } from 'node:os';

import { requireDefaultExport, requireDependency } from '../../../scripts/import-prerequisite.mjs';
const ts = await requireDefaultExport('typescript', () => import('typescript'), import.meta.url);
const { validateSecurityPosture, SECURITY_CBP_NO_RELATION } = await requireDependency('@objectstack/lint', () => import('@objectstack/lint'), import.meta.url);
const { validateSecurityPosture, SECURITY_CBP_NO_RELATION, SECURITY_CBP_AMBIGUOUS_RELATION } = await requireDependency('@objectstack/lint', () => import('@objectstack/lint'), import.meta.url);

const HERE = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(HERE, '../../..');
Expand DownExpand Up@@ -344,10 +348,10 @@ export function judgeFile(fileAbs, relPath, marker) {
const findings = validateSecurityPosture({ objects: [obj] }).filter((f) => f.severity === 'error');
const kept = [];
for (const f of findings) {
if (f.rule === SECURITY_CBP_NO_RELATION && !fieldsComplete) {
if ((f.rule === SECURITY_CBP_NO_RELATION || f.rule === SECURITY_CBP_AMBIGUOUS_RELATION) && !fieldsComplete) {
notices.push(
`${relPath}:${pageLine} object "${obj.name}": ${f.rule} suppressed — ` +
`the fields subtree is not statically evaluable (factory calls), so "no relation" would be a guess`,
`the fields subtree is not statically evaluable (factory calls), so the verdict would be a guess`,
);
continue;
}
Expand Down
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -297,6 +297,7 @@ export {
SECURITY_GRANT_EXPIRED_AT_AUTHORING,
SECURITY_DELEGATION_MISSING_REASON,
SECURITY_CBP_NO_RELATION,
SECURITY_CBP_AMBIGUOUS_RELATION,
} from './validate-security-posture.js';
export type { SecurityFinding, SecuritySeverity } from './validate-security-posture.js';

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .changeset/lint-cbp-ambiguous-master.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
'@objectstack/lint': minor
---

security lint: report a `controlled_by_parent` object whose master is decided by FIELD DECLARATION ORDER (#14747)

`SecurityPlugin.resolveCbpRelation` resolves the master a `controlled_by_parent`
object derives record-level access from through three tiers — a required
`master_detail`, then any `master_detail`, then a required `lookup` — and picks
inside a tier with `Array.prototype.find`. So when two or more candidates sit in
the tier that wins, the master is whichever one the field map happens to list
first. Measured on a real kernel: an object declaring two required lookups
resolved its security master to the first-declared one, and swapping the two
field declarations — nothing else — repointed every row's record-level access
to the other object. Nothing reported it: not `os validate`, not `os lint`, not
a boot warning.

New error id **`security-controlled-by-parent-ambiguous-relation`**, the mirror
image of `security-controlled-by-parent-no-relation` (#7503): that one reports
ZERO candidates, this one reports two or more. The message names every
candidate — field, type and master — in declaration order, says which tier was
tested, and says which candidate wins today and therefore which object access
derives from right now.

Only the **winning** tier is judged, and that is not a shortcut: the runtime's
`??` chain stops at the first tier that resolves, so a tie in a lower tier is
masked by a higher tier's single winner and is not a decision the platform ever
makes. An object with one required `master_detail` and two required lookups is
silent, and stays silent.

`error` rather than advisory, for the inverse of the usual reason. The other
error rules in this linter mirror a hard runtime refusal; this one has none to
mirror precisely BECAUSE the runtime does not refuse — it silently picks — so
author time is the only place the ambiguity can ever surface. What it does meet
is the admissibility bar the #7503 rule states: a self-contained property of the
object document, no per-permission-set nuance to adjudicate, and no legitimate
reading, since two tied candidates is not an author saying which master they
meant.

This **narrows the accept set of a gating rule** — error findings fail
`os validate` / `os compile`. Measured over the shipped corpus: the three
`controlled_by_parent` objects in the example apps (`showcase_invoice_line`,
`showcase_expense_line`, `crm_opportunity_line_item`) plus the 27
`ObjectSchema.create` sites the `check:doc-security-posture` gate reads across
226 marked prose blocks — **0 findings before and 0 after**. Each of the three
declares exactly one required `master_detail`, so tier 1 wins with a single
candidate. `showcase_invoice_line` is the interesting one: it also carries a
required `lookup`, and the rule is silent because that tie-free lower tier is
never reached.

No runtime behaviour changes. `resolveCbpRelation` in this package now reads its
tiers from one shared table so the two rules cannot disagree about which tier
wins, and its answer is unchanged by construction: `find` over a tier is the
first element `filter` over that tier keeps. The mirror's one deliberate
divergence from the runtime is kept — `reference` is the only spelling accepted
here (#5017), so a field carrying the rejected `reference_to` alias is not a
candidate and cannot create a tie.
18 changes: 11 additions & 7 deletions packages/lint/scripts/check-doc-security-posture.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,10 +62,14 @@
* - `SECURITY_OWD_ALIAS` / `SECURITY_EXTERNAL_WIDER` fire only on values that
* are static strings (the rule itself requires `typeof === 'string'`; the
* sentinel is not a string).
* - `SECURITY_CBP_NO_RELATION` reads the `fields` subtree, so when that
* subtree is not fully static its findings are SUPPRESSED with a printed
* notice — a `Field.master_detail(...)` factory call must not read as "no
* relation". (No marked block declares `controlled_by_parent` today; the
* - `SECURITY_CBP_NO_RELATION` and `SECURITY_CBP_AMBIGUOUS_RELATION` read the
* `fields` subtree, so when that subtree is not fully static their findings
* are SUPPRESSED with a printed notice — a `Field.master_detail(...)` factory
* call must not read as "no relation", and it must not read as "absent from
* the master_detail tiers" either: an opaque field is invisible to every tier
* predicate, so a single real `master_detail` masked by a factory call would
* hand the win to a lower tier and report a tie the platform never resolves
* (#14747). (No marked block declares `controlled_by_parent` today; the
* suppression exists so the first one that does cannot false-red.)
*
* Two shapes are refused loudly rather than skipped, because a silent skip is
Expand DownExpand Up@@ -125,7 +129,7 @@ import { tmpdir } from 'node:os';

import { requireDefaultExport, requireDependency } from '../../../scripts/import-prerequisite.mjs';
const ts = await requireDefaultExport('typescript', () => import('typescript'), import.meta.url);
const { validateSecurityPosture, SECURITY_CBP_NO_RELATION } = await requireDependency('@objectstack/lint', () => import('@objectstack/lint'), import.meta.url);
const { validateSecurityPosture, SECURITY_CBP_NO_RELATION, SECURITY_CBP_AMBIGUOUS_RELATION } = await requireDependency('@objectstack/lint', () => import('@objectstack/lint'), import.meta.url);

const HERE = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(HERE, '../../..');
Expand DownExpand Up@@ -344,10 +348,10 @@ export function judgeFile(fileAbs, relPath, marker) {
const findings = validateSecurityPosture({ objects: [obj] }).filter((f) => f.severity === 'error');
const kept = [];
for (const f of findings) {
if (f.rule === SECURITY_CBP_NO_RELATION && !fieldsComplete) {
if ((f.rule === SECURITY_CBP_NO_RELATION || f.rule === SECURITY_CBP_AMBIGUOUS_RELATION) && !fieldsComplete) {
notices.push(
`${relPath}:${pageLine} object "${obj.name}": ${f.rule} suppressed — ` +
`the fields subtree is not statically evaluable (factory calls), so "no relation" would be a guess`,
`the fields subtree is not statically evaluable (factory calls), so the verdict would be a guess`,
);
continue;
}
Expand Down
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -297,6 +297,7 @@ export {
SECURITY_GRANT_EXPIRED_AT_AUTHORING,
SECURITY_DELEGATION_MISSING_REASON,
SECURITY_CBP_NO_RELATION,
SECURITY_CBP_AMBIGUOUS_RELATION,
} from './validate-security-posture.js';
export type { SecurityFinding, SecuritySeverity } from './validate-security-posture.js';

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .changeset/lint-cbp-ambiguous-master.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
'@objectstack/lint': minor
---

security lint: report a `controlled_by_parent` object whose master is decided by FIELD DECLARATION ORDER (#14747)

`SecurityPlugin.resolveCbpRelation` resolves the master a `controlled_by_parent`
object derives record-level access from through three tiers — a required
`master_detail`, then any `master_detail`, then a required `lookup` — and picks
inside a tier with `Array.prototype.find`. So when two or more candidates sit in
the tier that wins, the master is whichever one the field map happens to list
first. Measured on a real kernel: an object declaring two required lookups
resolved its security master to the first-declared one, and swapping the two
field declarations — nothing else — repointed every row's record-level access
to the other object. Nothing reported it: not `os validate`, not `os lint`, not
a boot warning.

New error id **`security-controlled-by-parent-ambiguous-relation`**, the mirror
image of `security-controlled-by-parent-no-relation` (#7503): that one reports
ZERO candidates, this one reports two or more. The message names every
candidate — field, type and master — in declaration order, says which tier was
tested, and says which candidate wins today and therefore which object access
derives from right now.

Only the **winning** tier is judged, and that is not a shortcut: the runtime's
`??` chain stops at the first tier that resolves, so a tie in a lower tier is
masked by a higher tier's single winner and is not a decision the platform ever
makes. An object with one required `master_detail` and two required lookups is
silent, and stays silent.

`error` rather than advisory, for the inverse of the usual reason. The other
error rules in this linter mirror a hard runtime refusal; this one has none to
mirror precisely BECAUSE the runtime does not refuse — it silently picks — so
author time is the only place the ambiguity can ever surface. What it does meet
is the admissibility bar the #7503 rule states: a self-contained property of the
object document, no per-permission-set nuance to adjudicate, and no legitimate
reading, since two tied candidates is not an author saying which master they
meant.

This **narrows the accept set of a gating rule** — error findings fail
`os validate` / `os compile`. Measured over the shipped corpus: the three
`controlled_by_parent` objects in the example apps (`showcase_invoice_line`,
`showcase_expense_line`, `crm_opportunity_line_item`) plus the 27
`ObjectSchema.create` sites the `check:doc-security-posture` gate reads across
226 marked prose blocks — **0 findings before and 0 after**. Each of the three
declares exactly one required `master_detail`, so tier 1 wins with a single
candidate. `showcase_invoice_line` is the interesting one: it also carries a
required `lookup`, and the rule is silent because that tie-free lower tier is
never reached.

No runtime behaviour changes. `resolveCbpRelation` in this package now reads its
tiers from one shared table so the two rules cannot disagree about which tier
wins, and its answer is unchanged by construction: `find` over a tier is the
first element `filter` over that tier keeps. The mirror's one deliberate
divergence from the runtime is kept — `reference` is the only spelling accepted
here (#5017), so a field carrying the rejected `reference_to` alias is not a
candidate and cannot create a tie.
18 changes: 11 additions & 7 deletions packages/lint/scripts/check-doc-security-posture.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,10 +62,14 @@
* - `SECURITY_OWD_ALIAS` / `SECURITY_EXTERNAL_WIDER` fire only on values that
* are static strings (the rule itself requires `typeof === 'string'`; the
* sentinel is not a string).
* - `SECURITY_CBP_NO_RELATION` reads the `fields` subtree, so when that
* subtree is not fully static its findings are SUPPRESSED with a printed
* notice — a `Field.master_detail(...)` factory call must not read as "no
* relation". (No marked block declares `controlled_by_parent` today; the
* - `SECURITY_CBP_NO_RELATION` and `SECURITY_CBP_AMBIGUOUS_RELATION` read the
* `fields` subtree, so when that subtree is not fully static their findings
* are SUPPRESSED with a printed notice — a `Field.master_detail(...)` factory
* call must not read as "no relation", and it must not read as "absent from
* the master_detail tiers" either: an opaque field is invisible to every tier
* predicate, so a single real `master_detail` masked by a factory call would
* hand the win to a lower tier and report a tie the platform never resolves
* (#14747). (No marked block declares `controlled_by_parent` today; the
* suppression exists so the first one that does cannot false-red.)
*
* Two shapes are refused loudly rather than skipped, because a silent skip is
Expand DownExpand Up@@ -125,7 +129,7 @@ import { tmpdir } from 'node:os';

import { requireDefaultExport, requireDependency } from '../../../scripts/import-prerequisite.mjs';
const ts = await requireDefaultExport('typescript', () => import('typescript'), import.meta.url);
const { validateSecurityPosture, SECURITY_CBP_NO_RELATION } = await requireDependency('@objectstack/lint', () => import('@objectstack/lint'), import.meta.url);
const { validateSecurityPosture, SECURITY_CBP_NO_RELATION, SECURITY_CBP_AMBIGUOUS_RELATION } = await requireDependency('@objectstack/lint', () => import('@objectstack/lint'), import.meta.url);

const HERE = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(HERE, '../../..');
Expand DownExpand Up@@ -344,10 +348,10 @@ export function judgeFile(fileAbs, relPath, marker) {
const findings = validateSecurityPosture({ objects: [obj] }).filter((f) => f.severity === 'error');
const kept = [];
for (const f of findings) {
if (f.rule === SECURITY_CBP_NO_RELATION && !fieldsComplete) {
if ((f.rule === SECURITY_CBP_NO_RELATION || f.rule === SECURITY_CBP_AMBIGUOUS_RELATION) && !fieldsComplete) {
notices.push(
`${relPath}:${pageLine} object "${obj.name}": ${f.rule} suppressed — ` +
`the fields subtree is not statically evaluable (factory calls), so "no relation" would be a guess`,
`the fields subtree is not statically evaluable (factory calls), so the verdict would be a guess`,
);
continue;
}
Expand Down
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -297,6 +297,7 @@ export {
SECURITY_GRANT_EXPIRED_AT_AUTHORING,
SECURITY_DELEGATION_MISSING_REASON,
SECURITY_CBP_NO_RELATION,
SECURITY_CBP_AMBIGUOUS_RELATION,
} from './validate-security-posture.js';
export type { SecurityFinding, SecuritySeverity } from './validate-security-posture.js';

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .changeset/lint-cbp-ambiguous-master.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
'@objectstack/lint': minor
---

security lint: report a `controlled_by_parent` object whose master is decided by FIELD DECLARATION ORDER (#14747)

`SecurityPlugin.resolveCbpRelation` resolves the master a `controlled_by_parent`
object derives record-level access from through three tiers — a required
`master_detail`, then any `master_detail`, then a required `lookup` — and picks
inside a tier with `Array.prototype.find`. So when two or more candidates sit in
the tier that wins, the master is whichever one the field map happens to list
first. Measured on a real kernel: an object declaring two required lookups
resolved its security master to the first-declared one, and swapping the two
field declarations — nothing else — repointed every row's record-level access
to the other object. Nothing reported it: not `os validate`, not `os lint`, not
a boot warning.

New error id **`security-controlled-by-parent-ambiguous-relation`**, the mirror
image of `security-controlled-by-parent-no-relation` (#7503): that one reports
ZERO candidates, this one reports two or more. The message names every
candidate — field, type and master — in declaration order, says which tier was
tested, and says which candidate wins today and therefore which object access
derives from right now.

Only the **winning** tier is judged, and that is not a shortcut: the runtime's
`??` chain stops at the first tier that resolves, so a tie in a lower tier is
masked by a higher tier's single winner and is not a decision the platform ever
makes. An object with one required `master_detail` and two required lookups is
silent, and stays silent.

`error` rather than advisory, for the inverse of the usual reason. The other
error rules in this linter mirror a hard runtime refusal; this one has none to
mirror precisely BECAUSE the runtime does not refuse — it silently picks — so
author time is the only place the ambiguity can ever surface. What it does meet
is the admissibility bar the #7503 rule states: a self-contained property of the
object document, no per-permission-set nuance to adjudicate, and no legitimate
reading, since two tied candidates is not an author saying which master they
meant.

This **narrows the accept set of a gating rule** — error findings fail
`os validate` / `os compile`. Measured over the shipped corpus: the three
`controlled_by_parent` objects in the example apps (`showcase_invoice_line`,
`showcase_expense_line`, `crm_opportunity_line_item`) plus the 27
`ObjectSchema.create` sites the `check:doc-security-posture` gate reads across
226 marked prose blocks — **0 findings before and 0 after**. Each of the three
declares exactly one required `master_detail`, so tier 1 wins with a single
candidate. `showcase_invoice_line` is the interesting one: it also carries a
required `lookup`, and the rule is silent because that tie-free lower tier is
never reached.

No runtime behaviour changes. `resolveCbpRelation` in this package now reads its
tiers from one shared table so the two rules cannot disagree about which tier
wins, and its answer is unchanged by construction: `find` over a tier is the
first element `filter` over that tier keeps. The mirror's one deliberate
divergence from the runtime is kept — `reference` is the only spelling accepted
here (#5017), so a field carrying the rejected `reference_to` alias is not a
candidate and cannot create a tie.
18 changes: 11 additions & 7 deletions packages/lint/scripts/check-doc-security-posture.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,10 +62,14 @@
* - `SECURITY_OWD_ALIAS` / `SECURITY_EXTERNAL_WIDER` fire only on values that
* are static strings (the rule itself requires `typeof === 'string'`; the
* sentinel is not a string).
* - `SECURITY_CBP_NO_RELATION` reads the `fields` subtree, so when that
* subtree is not fully static its findings are SUPPRESSED with a printed
* notice — a `Field.master_detail(...)` factory call must not read as "no
* relation". (No marked block declares `controlled_by_parent` today; the
* - `SECURITY_CBP_NO_RELATION` and `SECURITY_CBP_AMBIGUOUS_RELATION` read the
* `fields` subtree, so when that subtree is not fully static their findings
* are SUPPRESSED with a printed notice — a `Field.master_detail(...)` factory
* call must not read as "no relation", and it must not read as "absent from
* the master_detail tiers" either: an opaque field is invisible to every tier
* predicate, so a single real `master_detail` masked by a factory call would
* hand the win to a lower tier and report a tie the platform never resolves
* (#14747). (No marked block declares `controlled_by_parent` today; the
* suppression exists so the first one that does cannot false-red.)
*
* Two shapes are refused loudly rather than skipped, because a silent skip is
Expand DownExpand Up@@ -125,7 +129,7 @@ import { tmpdir } from 'node:os';

import { requireDefaultExport, requireDependency } from '../../../scripts/import-prerequisite.mjs';
const ts = await requireDefaultExport('typescript', () => import('typescript'), import.meta.url);
const { validateSecurityPosture, SECURITY_CBP_NO_RELATION } = await requireDependency('@objectstack/lint', () => import('@objectstack/lint'), import.meta.url);
const { validateSecurityPosture, SECURITY_CBP_NO_RELATION, SECURITY_CBP_AMBIGUOUS_RELATION } = await requireDependency('@objectstack/lint', () => import('@objectstack/lint'), import.meta.url);

const HERE = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(HERE, '../../..');
Expand DownExpand Up@@ -344,10 +348,10 @@ export function judgeFile(fileAbs, relPath, marker) {
const findings = validateSecurityPosture({ objects: [obj] }).filter((f) => f.severity === 'error');
const kept = [];
for (const f of findings) {
if (f.rule === SECURITY_CBP_NO_RELATION && !fieldsComplete) {
if ((f.rule === SECURITY_CBP_NO_RELATION || f.rule === SECURITY_CBP_AMBIGUOUS_RELATION) && !fieldsComplete) {
notices.push(
`${relPath}:${pageLine} object "${obj.name}": ${f.rule} suppressed — ` +
`the fields subtree is not statically evaluable (factory calls), so "no relation" would be a guess`,
`the fields subtree is not statically evaluable (factory calls), so the verdict would be a guess`,
);
continue;
}
Expand Down
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -297,6 +297,7 @@ export {
SECURITY_GRANT_EXPIRED_AT_AUTHORING,
SECURITY_DELEGATION_MISSING_REASON,
SECURITY_CBP_NO_RELATION,
SECURITY_CBP_AMBIGUOUS_RELATION,
} from './validate-security-posture.js';
export type { SecurityFinding, SecuritySeverity } from './validate-security-posture.js';

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .changeset/lint-cbp-ambiguous-master.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
'@objectstack/lint': minor
---

security lint: report a `controlled_by_parent` object whose master is decided by FIELD DECLARATION ORDER (#14747)

`SecurityPlugin.resolveCbpRelation` resolves the master a `controlled_by_parent`
object derives record-level access from through three tiers — a required
`master_detail`, then any `master_detail`, then a required `lookup` — and picks
inside a tier with `Array.prototype.find`. So when two or more candidates sit in
the tier that wins, the master is whichever one the field map happens to list
first. Measured on a real kernel: an object declaring two required lookups
resolved its security master to the first-declared one, and swapping the two
field declarations — nothing else — repointed every row's record-level access
to the other object. Nothing reported it: not `os validate`, not `os lint`, not
a boot warning.

New error id **`security-controlled-by-parent-ambiguous-relation`**, the mirror
image of `security-controlled-by-parent-no-relation` (#7503): that one reports
ZERO candidates, this one reports two or more. The message names every
candidate — field, type and master — in declaration order, says which tier was
tested, and says which candidate wins today and therefore which object access
derives from right now.

Only the **winning** tier is judged, and that is not a shortcut: the runtime's
`??` chain stops at the first tier that resolves, so a tie in a lower tier is
masked by a higher tier's single winner and is not a decision the platform ever
makes. An object with one required `master_detail` and two required lookups is
silent, and stays silent.

`error` rather than advisory, for the inverse of the usual reason. The other
error rules in this linter mirror a hard runtime refusal; this one has none to
mirror precisely BECAUSE the runtime does not refuse — it silently picks — so
author time is the only place the ambiguity can ever surface. What it does meet
is the admissibility bar the #7503 rule states: a self-contained property of the
object document, no per-permission-set nuance to adjudicate, and no legitimate
reading, since two tied candidates is not an author saying which master they
meant.

This **narrows the accept set of a gating rule** — error findings fail
`os validate` / `os compile`. Measured over the shipped corpus: the three
`controlled_by_parent` objects in the example apps (`showcase_invoice_line`,
`showcase_expense_line`, `crm_opportunity_line_item`) plus the 27
`ObjectSchema.create` sites the `check:doc-security-posture` gate reads across
226 marked prose blocks — **0 findings before and 0 after**. Each of the three
declares exactly one required `master_detail`, so tier 1 wins with a single
candidate. `showcase_invoice_line` is the interesting one: it also carries a
required `lookup`, and the rule is silent because that tie-free lower tier is
never reached.

No runtime behaviour changes. `resolveCbpRelation` in this package now reads its
tiers from one shared table so the two rules cannot disagree about which tier
wins, and its answer is unchanged by construction: `find` over a tier is the
first element `filter` over that tier keeps. The mirror's one deliberate
divergence from the runtime is kept — `reference` is the only spelling accepted
here (#5017), so a field carrying the rejected `reference_to` alias is not a
candidate and cannot create a tie.
18 changes: 11 additions & 7 deletions packages/lint/scripts/check-doc-security-posture.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,10 +62,14 @@
* - `SECURITY_OWD_ALIAS` / `SECURITY_EXTERNAL_WIDER` fire only on values that
* are static strings (the rule itself requires `typeof === 'string'`; the
* sentinel is not a string).
* - `SECURITY_CBP_NO_RELATION` reads the `fields` subtree, so when that
* subtree is not fully static its findings are SUPPRESSED with a printed
* notice — a `Field.master_detail(...)` factory call must not read as "no
* relation". (No marked block declares `controlled_by_parent` today; the
* - `SECURITY_CBP_NO_RELATION` and `SECURITY_CBP_AMBIGUOUS_RELATION` read the
* `fields` subtree, so when that subtree is not fully static their findings
* are SUPPRESSED with a printed notice — a `Field.master_detail(...)` factory
* call must not read as "no relation", and it must not read as "absent from
* the master_detail tiers" either: an opaque field is invisible to every tier
* predicate, so a single real `master_detail` masked by a factory call would
* hand the win to a lower tier and report a tie the platform never resolves
* (#14747). (No marked block declares `controlled_by_parent` today; the
* suppression exists so the first one that does cannot false-red.)
*
* Two shapes are refused loudly rather than skipped, because a silent skip is
Expand DownExpand Up@@ -125,7 +129,7 @@ import { tmpdir } from 'node:os';

import { requireDefaultExport, requireDependency } from '../../../scripts/import-prerequisite.mjs';
const ts = await requireDefaultExport('typescript', () => import('typescript'), import.meta.url);
const { validateSecurityPosture, SECURITY_CBP_NO_RELATION } = await requireDependency('@objectstack/lint', () => import('@objectstack/lint'), import.meta.url);
const { validateSecurityPosture, SECURITY_CBP_NO_RELATION, SECURITY_CBP_AMBIGUOUS_RELATION } = await requireDependency('@objectstack/lint', () => import('@objectstack/lint'), import.meta.url);

const HERE = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(HERE, '../../..');
Expand DownExpand Up@@ -344,10 +348,10 @@ export function judgeFile(fileAbs, relPath, marker) {
const findings = validateSecurityPosture({ objects: [obj] }).filter((f) => f.severity === 'error');
const kept = [];
for (const f of findings) {
if (f.rule === SECURITY_CBP_NO_RELATION && !fieldsComplete) {
if ((f.rule === SECURITY_CBP_NO_RELATION || f.rule === SECURITY_CBP_AMBIGUOUS_RELATION) && !fieldsComplete) {
notices.push(
`${relPath}:${pageLine} object "${obj.name}": ${f.rule} suppressed — ` +
`the fields subtree is not statically evaluable (factory calls), so "no relation" would be a guess`,
`the fields subtree is not statically evaluable (factory calls), so the verdict would be a guess`,
);
continue;
}
Expand Down
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -297,6 +297,7 @@ export {
SECURITY_GRANT_EXPIRED_AT_AUTHORING,
SECURITY_DELEGATION_MISSING_REASON,
SECURITY_CBP_NO_RELATION,
SECURITY_CBP_AMBIGUOUS_RELATION,
} from './validate-security-posture.js';
export type { SecurityFinding, SecuritySeverity } from './validate-security-posture.js';

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .changeset/lint-cbp-ambiguous-master.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
'@objectstack/lint': minor
---

security lint: report a `controlled_by_parent` object whose master is decided by FIELD DECLARATION ORDER (#14747)

`SecurityPlugin.resolveCbpRelation` resolves the master a `controlled_by_parent`
object derives record-level access from through three tiers — a required
`master_detail`, then any `master_detail`, then a required `lookup` — and picks
inside a tier with `Array.prototype.find`. So when two or more candidates sit in
the tier that wins, the master is whichever one the field map happens to list
first. Measured on a real kernel: an object declaring two required lookups
resolved its security master to the first-declared one, and swapping the two
field declarations — nothing else — repointed every row's record-level access
to the other object. Nothing reported it: not `os validate`, not `os lint`, not
a boot warning.

New error id **`security-controlled-by-parent-ambiguous-relation`**, the mirror
image of `security-controlled-by-parent-no-relation` (#7503): that one reports
ZERO candidates, this one reports two or more. The message names every
candidate — field, type and master — in declaration order, says which tier was
tested, and says which candidate wins today and therefore which object access
derives from right now.

Only the **winning** tier is judged, and that is not a shortcut: the runtime's
`??` chain stops at the first tier that resolves, so a tie in a lower tier is
masked by a higher tier's single winner and is not a decision the platform ever
makes. An object with one required `master_detail` and two required lookups is
silent, and stays silent.

`error` rather than advisory, for the inverse of the usual reason. The other
error rules in this linter mirror a hard runtime refusal; this one has none to
mirror precisely BECAUSE the runtime does not refuse — it silently picks — so
author time is the only place the ambiguity can ever surface. What it does meet
is the admissibility bar the #7503 rule states: a self-contained property of the
object document, no per-permission-set nuance to adjudicate, and no legitimate
reading, since two tied candidates is not an author saying which master they
meant.

This **narrows the accept set of a gating rule** — error findings fail
`os validate` / `os compile`. Measured over the shipped corpus: the three
`controlled_by_parent` objects in the example apps (`showcase_invoice_line`,
`showcase_expense_line`, `crm_opportunity_line_item`) plus the 27
`ObjectSchema.create` sites the `check:doc-security-posture` gate reads across
226 marked prose blocks — **0 findings before and 0 after**. Each of the three
declares exactly one required `master_detail`, so tier 1 wins with a single
candidate. `showcase_invoice_line` is the interesting one: it also carries a
required `lookup`, and the rule is silent because that tie-free lower tier is
never reached.

No runtime behaviour changes. `resolveCbpRelation` in this package now reads its
tiers from one shared table so the two rules cannot disagree about which tier
wins, and its answer is unchanged by construction: `find` over a tier is the
first element `filter` over that tier keeps. The mirror's one deliberate
divergence from the runtime is kept — `reference` is the only spelling accepted
here (#5017), so a field carrying the rejected `reference_to` alias is not a
candidate and cannot create a tie.
18 changes: 11 additions & 7 deletions packages/lint/scripts/check-doc-security-posture.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,10 +62,14 @@
* - `SECURITY_OWD_ALIAS` / `SECURITY_EXTERNAL_WIDER` fire only on values that
* are static strings (the rule itself requires `typeof === 'string'`; the
* sentinel is not a string).
* - `SECURITY_CBP_NO_RELATION` reads the `fields` subtree, so when that
* subtree is not fully static its findings are SUPPRESSED with a printed
* notice — a `Field.master_detail(...)` factory call must not read as "no
* relation". (No marked block declares `controlled_by_parent` today; the
* - `SECURITY_CBP_NO_RELATION` and `SECURITY_CBP_AMBIGUOUS_RELATION` read the
* `fields` subtree, so when that subtree is not fully static their findings
* are SUPPRESSED with a printed notice — a `Field.master_detail(...)` factory
* call must not read as "no relation", and it must not read as "absent from
* the master_detail tiers" either: an opaque field is invisible to every tier
* predicate, so a single real `master_detail` masked by a factory call would
* hand the win to a lower tier and report a tie the platform never resolves
* (#14747). (No marked block declares `controlled_by_parent` today; the
* suppression exists so the first one that does cannot false-red.)
*
* Two shapes are refused loudly rather than skipped, because a silent skip is
Expand DownExpand Up@@ -125,7 +129,7 @@ import { tmpdir } from 'node:os';

import { requireDefaultExport, requireDependency } from '../../../scripts/import-prerequisite.mjs';
const ts = await requireDefaultExport('typescript', () => import('typescript'), import.meta.url);
const { validateSecurityPosture, SECURITY_CBP_NO_RELATION } = await requireDependency('@objectstack/lint', () => import('@objectstack/lint'), import.meta.url);
const { validateSecurityPosture, SECURITY_CBP_NO_RELATION, SECURITY_CBP_AMBIGUOUS_RELATION } = await requireDependency('@objectstack/lint', () => import('@objectstack/lint'), import.meta.url);

const HERE = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(HERE, '../../..');
Expand DownExpand Up@@ -344,10 +348,10 @@ export function judgeFile(fileAbs, relPath, marker) {
const findings = validateSecurityPosture({ objects: [obj] }).filter((f) => f.severity === 'error');
const kept = [];
for (const f of findings) {
if (f.rule === SECURITY_CBP_NO_RELATION && !fieldsComplete) {
if ((f.rule === SECURITY_CBP_NO_RELATION || f.rule === SECURITY_CBP_AMBIGUOUS_RELATION) && !fieldsComplete) {
notices.push(
`${relPath}:${pageLine} object "${obj.name}": ${f.rule} suppressed — ` +
`the fields subtree is not statically evaluable (factory calls), so "no relation" would be a guess`,
`the fields subtree is not statically evaluable (factory calls), so the verdict would be a guess`,
);
continue;
}
Expand Down
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -297,6 +297,7 @@ export {
SECURITY_GRANT_EXPIRED_AT_AUTHORING,
SECURITY_DELEGATION_MISSING_REASON,
SECURITY_CBP_NO_RELATION,
SECURITY_CBP_AMBIGUOUS_RELATION,
} from './validate-security-posture.js';
export type { SecurityFinding, SecuritySeverity } from './validate-security-posture.js';

Expand Down
Loading
Loading