Skip to content

Commit 6606016

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-24410-formula-related-field-refusal
2 parents 0485c68 + 9112bcd commit 6606016

48 files changed

Lines changed: 1056 additions & 83 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): a record read that hydrates a file field no longer makes a `sys_file` access attempt for a reader without `sys_file` read
6+
7+
The engine expands a stored file id by reading `sys_file`, and it issued that read as the caller. A member who may read the record but not `sys_file` (the platform default) was refused it, and the security layer logged `[Security] Access denied: operation 'find' on object 'sys_file'` at WARN on every by-id and list read by that member, although the body served was right. Those lines buried the real denials in the operator's log.
8+
9+
The engine now asks the security service's `canReadObject('sys_file')` first, the same object-level read gate the security middleware applies, and serves a refused caller the field-owned answer without the read. Nothing else changes:
10+
11+
- The served body is byte-identical, by id and by list, for a reader with and without `sys_file` read. A file owned by the record still hydrates, and every other id still reads `{ id, metadataRefused: true }`.
12+
- A direct `GET /api/v1/data/sys_file/:id` by the same reader still answers `403 PERMISSION_DENIED` and still logs its denial.
13+
- With no security service, or one that offers no `canReadObject`, the engine reads as the caller, as before.
14+
15+
The ask is one cached permission check per read, not per row.
16+
17+
Clause-②: no (no accept set or public surface moves: the served bodies and every refusal are unchanged; owes the seat's review of record at `TIER_DEFAULT` before the queue)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/plugin-audit": patch
3+
"@objectstack/cli": patch
4+
---
5+
6+
fix: an activity row is written only when an organization is derivable, never with a NULL organization, and the v18 ownership ceremony deletes the existing NULL activity rows it cannot attribute (ADR-0131 D9)
7+
8+
Clause-②: no
9+
10+
- **`@objectstack/plugin-audit`.** The audit writer's activity mirror (`sys_activity`) takes its organization from the record, then from the acting session, then from the `tenancy` service's Default Organization under `single`. When none of them names one, the mirror now writes no activity row at all. Before, it wrote a row with `organization_id` NULL. Under a wall that row was readable by no organization member. The change is still recorded: the compliance ledger row (`sys_audit_log`) is written as before, with its `tenant_id` attribution. Under `single` the Default Organization is always derivable, so nothing changes there. A record that names its organization keeps its activity row, with that organization.
11+
- **`@objectstack/cli`.** `os migrate organization-ownership` still attributes a NULL `sys_activity` row when its record carries an organization. On a walled database (any posture but `single`), the mirror-deletion step now deletes the NULL rows no anchor attributes, such as activity about a sign-up's `sys_user`. Before, those rows were reported as unattributable and held certification. They are 14-day telemetry that the mirror no longer writes, and `sys_audit_log` keeps each change. The plan counts them as the departure `underivable-activity`, and warns before the first write that `--apply` deletes them. The apply report lists the count as the table's `deleted`. Under `single` the Default Organization takes these rows, as before, and nothing is deleted.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/mcp': patch
3+
---
4+
5+
Clause-②: no (copy only: no accept set or public surface moves; owes the seat's review of record at `TIER_DEFAULT` before the queue)
6+
7+
fix(mcp): the Connect-an-Agent page subtitle says "this system"
8+
9+
- **What changed.** The Connect-an-Agent page header now reads "Give any MCP-capable AI client governed access to this system — every call runs under the caller's own permissions and row-level security." Inside the running system, user-facing copy names the system the caller is in as "this system".
10+
- **Unchanged.** The page's name, layout, components and navigation entries, and every MCP route and payload.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
Clause-②: no (copy only: no accept set or public surface moves; owes the seat's review of record at `TIER_DEFAULT` before the queue)
6+
7+
fix(platform-objects): SSO provider, OAuth application and Connect-an-Agent copy says "this system"
8+
9+
- **What changed.** Inside the running system, user-facing copy names the system the user is in as "this system". The `sys_sso_provider` object description ("External SSO identity providers (OIDC / SAML) this system federates login to"), the `clientId` help text of its Register SSO Provider action ("OAuth client ID issued by the IdP for this system."), and the OAuth applications grid's empty state ("Register an application to let its clients sign users in with this system.") now say so. The `en` copy of the Connect-an-Agent page subtitle follows the page's new text.
10+
- **Translations.** The zh-CN, ja-JP and es-ES values of the same strings are re-translated, and the recorded source hash of the Connect-an-Agent subtitle moves to the new English text in all three, so those locales keep serving their translation rather than falling back to English.
11+
- **Unchanged.** Every object, field, action and page name, and all behaviour.

‎packages/cli/src/utils/organization-ownership-apply.ts‎

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
predicateSql,
2929
PRIMARY_DATABASE,
3030
Sql,
31+
underivableDepartureOf,
3132
type AttributionSink,
3233
type MetadataConflict,
3334
type MetadataKeyRow,
@@ -117,7 +118,11 @@ import type { DeclaredSharingRuleSkip } from '@objectstack/plugin-sharing';
117118
* C3 stage 9): every grant naming a mirror row is renamed to the rule's
118119
* NAME, which the registry must resolve, and only then is the mirror
119120
* deleted ({@link runDeclaredRuleMirrorDeletion}). It runs again on a
120-
* checkpointed run while a mirror stands.
121+
* checkpointed run while a mirror stands. Before them, a fate-3 table's
122+
* `underivableDeparture` (`sys_activity`, ruling record 6257510973 item
123+
* 3): the NULL rows no anchor attributes once attribution is done are
124+
* deleted — never under `single`, where the Default Organization takes
125+
* them ({@link runUnderivableDeletion}).
121126
* 4h. `history-renumber` — seat answer Q2 A: `sys_metadata_history`'s
122127
* `event_seq` and every `(type, name, version)` unique environment-wide
123128
* before the column goes. Every lineage still tagged to an organization
@@ -184,7 +189,7 @@ import type { DeclaredSharingRuleSkip } from '@objectstack/plugin-sharing';
184189
*
185190
* ## What it never does
186191
*
187-
* ⛔ Delete a row outside fate 2. ⛔ Give an unattributable row an owner:
192+
* ⛔ Delete a row outside fate 2 and a declared `underivableDeparture`. ⛔ Give an unattributable row an owner:
188193
* owners come only from the plan's anchors and, under `single`, the Default
189194
* Organization. ⛔ Create any organization but the Default Organization, and
190195
* that one only under `single` on a database that holds none. ⛔ Run at boot:
@@ -2526,6 +2531,11 @@ async function runMirrorDeletion(run: Run): Promise<StepReport> {
25262531
const kept = Math.max(0, table.rows!.total - (run.deleted.get(table.object) ?? 0));
25272532
if (kept > 0) notes.push(`${table.object}: ${kept} row(s) are not mirrors (a named category or their non-seeded provenance claims them) and stay.`);
25282533
}
2534+
const underivable = await runUnderivableDeletion(run);
2535+
total += underivable.deleted;
2536+
if (underivable.blockers.length) {
2537+
return { step: 'mirror-deletion', status: 'blocked', summary: { deleted: total }, blockers: underivable.blockers, notes };
2538+
}
25292539
const declared = await runDeclaredRuleMirrorDeletion(run);
25302540
if (declared.status !== 'done') return { ...declared, summary: { ...declared.summary, deleted: total }, notes: [...notes, ...declared.notes] };
25312541
return {
@@ -2537,6 +2547,65 @@ async function runMirrorDeletion(run: Run): Promise<StepReport> {
25372547
};
25382548
}
25392549

2550+
// ── Step 4, continued: the rows no anchor attributes (ruling record 6257510973 item 3) ─
2551+
2552+
/**
2553+
* The NULL rows of a fate-3 table that declares an `underivableDeparture`
2554+
* (`sys_activity`: activity is never a NULL row, and the existing ones are
2555+
* deleted): once attribution has written every owner it derives, every row
2556+
* still NULL is one of them. Read from a fresh plan, so the delete takes
2557+
* exactly what that plan classifies: a table on which it still derives an
2558+
* owner, or reports a row, blocks with nothing deleted. Never under `single`
2559+
* ({@link underivableDepartureOf}).
2560+
*/
2561+
/** Whether the plan this run started from counts a row an `underivableDeparture` deletes. */
2562+
function underivableRowsDue(run: Run): boolean {
2563+
return run.plan.tables.some((t) => {
2564+
const entry = mirrorEntry(run, t.object);
2565+
const id = entry ? underivableDepartureOf(entry, run.opts.posture)?.id : undefined;
2566+
return id !== undefined && t.departures.some((d) => d.id === id && d.rows > 0);
2567+
});
2568+
}
2569+
2570+
async function runUnderivableDeletion(run: Run): Promise<{ deleted: number; blockers: string[] }> {
2571+
const declares = (object: string): string | undefined => {
2572+
const entry = mirrorEntry(run, object);
2573+
return entry ? underivableDepartureOf(entry, run.opts.posture)?.id : undefined;
2574+
};
2575+
if (!run.plan.tables.some((t) => t.physical === 'present' && declares(t.object))) return { deleted: 0, blockers: [] };
2576+
const plan = await planFor(run);
2577+
let deleted = 0;
2578+
const blockers: string[] = [];
2579+
for (const table of plan.tables) {
2580+
const id = declares(table.object);
2581+
const leaving = id ? table.departures.find((d) => d.id === id) : undefined;
2582+
if (!leaving || leaving.rows === 0 || table.physical !== 'present') continue;
2583+
const deriving = Number(table.fateCounts.rowsToAttribute ?? 0);
2584+
if (deriving > 0 || table.unattributable.length > 0 || leaving.rows !== table.rows!.organizationNull) {
2585+
blockers.push(
2586+
`${table.object}: ${leaving.rows} row(s) no anchor attributes are due for deletion, but the table still holds ` +
2587+
`${table.rows!.organizationNull} NULL row(s) (${deriving} an anchor attributes, ${table.unattributable.length} ` +
2588+
'reported), so nothing was deleted. Re-run --apply: the attribution step runs again first.',
2589+
);
2590+
continue;
2591+
}
2592+
for (const physical of table.tables) {
2593+
const { sql } = databaseFor(run, physical);
2594+
const where = `${sql.q(ORG, physical)} IS NULL`;
2595+
const count = (): Promise<number> => sql.count(physical, `SELECT COUNT(*) AS n FROM ${sql.q(physical, physical)} WHERE ${where}`);
2596+
const before = await count();
2597+
if (before === 0) continue;
2598+
await execute(run, physical, `DELETE FROM ${sql.q(physical, physical)} WHERE ${where}`, []);
2599+
const left = await count();
2600+
if (left !== 0) throw new Error(`The delete on ${physical} left ${left} organization-less row(s) behind; nothing past this table was deleted.`);
2601+
run.deleted.set(table.object, (run.deleted.get(table.object) ?? 0) + before);
2602+
deleted += before;
2603+
run.opts.onProgress?.(`mirror deletion: ${before} organization-less row(s) of ${physical} no anchor attributes`);
2604+
}
2605+
}
2606+
return { deleted, blockers };
2607+
}
2608+
25402609
// ── Step 4, continued: the declared sharing-rule mirrors (ADR-0131 C3 stage 9) ─
25412610

25422611
const SHARING_RULES = 'sys_sharing_rule';
@@ -3097,6 +3166,23 @@ export async function applyOrganizationOwnership(opts: ApplyOptions): Promise<Or
30973166
// checkpointed run while one stands: a database whose ceremony completed on
30983167
// a release whose boot still seeded them carries them (advisory then), and
30993168
// the step that deletes them is this release's.
3169+
// So are the rows no anchor attributes, while one stands: a row written
3170+
// NULL since the run that finished the step is one too.
3171+
if (done && step === 'mirror-deletion' && underivableRowsDue(run)) {
3172+
const again = await runUnderivableDeletion(run);
3173+
const report: StepReport = again.blockers.length
3174+
? { step, status: 'blocked', summary: { deleted: again.deleted }, blockers: again.blockers, notes: [] }
3175+
: { step, status: 'done', summary: { deleted: again.deleted }, blockers: [], notes: [] };
3176+
steps.push(report);
3177+
if (report.status !== 'done') {
3178+
stopped = true;
3179+
continue;
3180+
}
3181+
checkpoint.completed[step] = { at: now().toISOString(), summary: report.summary };
3182+
await opts.ledger.write(checkpoint, { complete: false, blocking: 0, advisory: 0 });
3183+
run.columns.clear();
3184+
if (!(await declaredRuleMirrorsDue(run))) continue;
3185+
}
31003186
if (done && step === 'mirror-deletion' && (await declaredRuleMirrorsDue(run))) {
31013187
const again = await runDeclaredRuleMirrorDeletion(run);
31023188
steps.push(again);

0 commit comments

Comments
 (0)