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
35 changes: 35 additions & 0 deletions .changeset/20513-objectql-runtime-strings-state-the-decision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
'@objectstack/objectql': patch
---

objectql refusals, log lines and metadata text no longer cite tracker numbers; each states the reason in words

Clause-②: no

Many messages the query engine shows to authors, administrators and operators ended with an
issue-tracker number where the reason belonged. The number goes, and where the sentence did not
already say what was decided, it now does:

- Refusals: the bulk update and bulk delete row-scoping refusals now name the seed they are missing
(the AST seeded before the middleware chain, which RLS and sharing compose their row-scoping onto,
so a bulk write reaches only the rows the caller may edit); the hook-target rebind refusal says
why `delete()` stopped honouring a rebind (a handler that silently redirects which row gets
deleted is a trap) and names the `dispatchUnscopedMultiWrite` registration the whole-operation
dispatch goes to, on update and delete alike. The unknown-option, filter-array,
credential-aggregation, HAVING-operator, empty-hook-target, strict read-only and system-write
organization refusals lose only the citation, because their sentences already said it.
- Metadata text: the lifecycle `retention_overrides` setting description and the search companion
field description lose their citation.
- Log lines: the non-atomic cascade warning says a single-datasource cascade is now one
transaction; the system-ledger transaction line calls the ledger the one class carved out of the
cross-datasource write refusal; the dangling-reference audit summary says findings are reported,
never rewritten, because a system-context write is exempt from the write-time reference check;
the legacy `apiMethods` warning says the authorable values are the six primitives only, every
other operation being derived from them or retired; the two unevaluable-rule warnings say such a
rule fails closed and is never skipped. The ADR-0104 value-shape gate lines, the delegated
protocol-assembly line and the read-only and runtime-owned strip warnings lose only the citation.

The `findOne` no-predicate refusal keeps its citation for now: `@objectstack/metadata-core`
carries a byte-identical copy that this package's tests compare against, and both move together.

Text only: no error code, field name, status or behaviour changes.
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ describe('#6437 — the refusal message is composed from `drops`, not from the c
`API-boundary caller, isSystem included. A value DERIVED by a beforeUpdate hook is ` +
`not a caller write and is never stripped — that is the sanctioned write path for a ` +
`conditionally-locked derived field). To let the strip happen and merely observe it, drop ` +
`strictReadonlyWrites and pass options.onFieldsDropped instead (#3407).`,
`strictReadonlyWrites and pass options.onFieldsDropped instead.`,
);
});

Expand Down
38 changes: 22 additions & 16 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -770,7 +770,7 @@ function rejectUnknownEngineOptions(
throw new Error(
`${operation}('${object}') does not recognise option${unknown.length > 1 ? 's' : ''} ` +
`${details.join('; ')}. The engine executes none of ${unknown.length > 1 ? 'them' : 'it'}, ` +
`so the call would succeed with the option silently ignored (#4371). ` +
`so the call would succeed with the option silently ignored. ` +
`Legal keys for ${operation}: ${[...legal].sort().join(', ')}.`,
);
}
Expand Down Expand Up @@ -1071,7 +1071,7 @@ function lowerWhereFilterArray<T extends object | undefined>(
`${JSON.stringify(where)}. A filter array is a comparison [field, operator, value], ` +
`a logical node ["and"|"or", ...conditions], or a list of those — it is INPUT-ONLY ` +
`sugar (spec 'FilterArray'), lowered to a FilterCondition here before any driver sees ` +
`it (#5158). This value cannot be lowered, and an unapplied filter would have returned ` +
`it. This value cannot be lowered, and an unapplied filter would have returned ` +
`the UNFILTERED result set. Recognised operators: ` +
`${[...VALID_AST_OPERATORS].sort().join(', ')}. Infix joins ([condA, "or", condB]) are ` +
`NOT one of the shapes — write the prefix form ["or", condA, condB].`,
Expand All @@ -1094,7 +1094,7 @@ function lowerWhereFilterArray<T extends object | undefined>(
throw new Error(
`${operation}('${object}'): filter array ${JSON.stringify(where)} passed isFilterAST() ` +
`but parseFilterAST() lowered it to nothing. Refusing rather than running the query ` +
`unfiltered (#5158).`,
`unfiltered.`,
);
}
// [#5869] Door 2's half of the same check USED to be a second
Expand Down Expand Up @@ -9616,7 +9616,7 @@ export class ObjectQL implements IObjectQLEngine {
FILE_REFERENCES_MIGRATION_ID,
'[value-shape] this deployment has verified the file-as-reference migration — ' +
'media value shapes are enforced and released field files may be collected ' +
'(ADR-0104 / #3617)',
'(ADR-0104)',
);
}

Expand Down Expand Up @@ -9664,7 +9664,7 @@ export class ObjectQL implements IObjectQLEngine {
'valueShapesMigrationVerified',
VALUE_SHAPES_MIGRATION_ID,
'[value-shape] this deployment has verified the value-shape scan — reference and ' +
'structured-JSON value shapes are enforced (ADR-0104 / #3438)',
'structured-JSON value shapes are enforced (ADR-0104)',
);
}

Expand Down Expand Up @@ -9967,7 +9967,7 @@ export class ObjectQL implements IObjectQLEngine {
'no byte is deleted on evidence this deployment has contradicted. Fix the data and run ' +
'`os migrate ' +
(migrationId === FILE_REFERENCES_MIGRATION_ID ? 'files-to-references' : 'value-shapes') +
' --apply` to clear it (ADR-0104 / #4797).',
' --apply` to clear it (ADR-0104).',
);
})
.catch((err: any) => {
Expand All @@ -9978,7 +9978,7 @@ export class ObjectQL implements IObjectQLEngine {
`[value-shape] could not record the observed deviation for '${migrationId}' ` +
`(${err?.message ?? err}) — the ledger still authorises irreversible collection while ` +
'this deployment holds a value its own contract rejects; run the migration to ' +
're-derive the gate (#4797)',
're-derive the gate',
);
});
}
Expand Down Expand Up @@ -10069,7 +10069,7 @@ export class ObjectQL implements IObjectQLEngine {
`(${tally?.first.object}.${tally?.first.field}: ${tally?.first.detail}). ` +
'The gate is closed again — fix the data, then run `os migrate ' +
(migrationId === FILE_REFERENCES_MIGRATION_ID ? 'files-to-references' : 'value-shapes') +
' --apply` to re-earn it (ADR-0104 / #4769).',
' --apply` to re-earn it (ADR-0104).',
);
})
.catch((err: any) => {
Expand All @@ -10078,7 +10078,7 @@ export class ObjectQL implements IObjectQLEngine {
this.logger.warn(
`[value-shape] could not revoke the creation attestation for '${migrationId}' ` +
`(${err?.message ?? err}) — the ledger still claims this deployment is verified ` +
'while its data contradicts that; run the migration to re-derive it (#4769)',
'while its data contradicts that; run the migration to re-derive it',
);
});
}
Expand Down Expand Up @@ -10154,15 +10154,15 @@ export class ObjectQL implements IObjectQLEngine {
'[value-shape] media values are checked but NOT enforced here, and released files are ' +
'never collected — this deployment has not verified its file migration. Run ' +
'`os migrate files-to-references` (dry run) to see what it would do, then `--apply` ' +
'to close the gate (ADR-0104 / #3617).',
'to close the gate (ADR-0104).',
);
}
if (covered && !(await this.readMigrationFlagVerified(VALUE_SHAPES_MIGRATION_ID)).verified) {
this.logger.info(
'[value-shape] reference and structured-JSON values are checked but NOT enforced here — ' +
'this deployment has not verified its value-shape scan. Run `os migrate value-shapes` ' +
'(dry run) to see what it would report, then `--apply` to close the gate ' +
'(ADR-0104 / #3438).',
'(ADR-0104).',
);
}
} catch {
Expand Down Expand Up @@ -14017,7 +14017,9 @@ export class ObjectQL implements IObjectQLEngine {
if (!ast) {
throw new Error(
`[Security] Refusing bulk update on '${object}': row-scoping AST was not seeded ` +
`(the predicate branch was reached without the #2982 seed).`,
`(the predicate branch was reached without the AST seeded before the middleware ` +
`chain — the one RLS and sharing compose their row-scoping onto, so that a bulk ` +
`write reaches only the rows this caller may edit).`,
);
}
// [#9974] The unscoped-multi shape check, BEFORE the matched-row
Expand Down Expand Up @@ -15199,7 +15201,8 @@ export class ObjectQL implements IObjectQLEngine {
`Cascade delete of '${object}' cannot run as one unit of work: the cascade reaches an object routed ` +
`to a datasource other than the default one ('${this.defaultDriver ?? '<none>'}'), and a transaction ` +
"covers one driver's connection only (ADR-0119 D1 — no two-phase commit). The cascade therefore runs " +
'UNWRAPPED, exactly as it did before #7413: if a later dependent refuses the delete, the rows already ' +
'UNWRAPPED, as every cascade did before a single-datasource cascade was made one transaction: if a ' +
'later dependent refuses the delete, the rows already ' +
'removed stay removed while the call rejects. Route the cascading objects to one datasource to get the ' +
'atomic path. Reported once per object per engine instance.',
{ object, defaultDatasource: this.defaultDriver ?? undefined },
Expand Down Expand Up @@ -16337,7 +16340,9 @@ export class ObjectQL implements IObjectQLEngine {
if (!ast) {
throw new Error(
`[Security] Refusing bulk delete on '${object}': row-scoping AST was not seeded ` +
`(the predicate branch was reached without the #2982 seed).`,
`(the predicate branch was reached without the AST seeded before the middleware ` +
`chain — the one RLS and sharing compose their row-scoping onto, so that a bulk ` +
`write reaches only the rows this caller may edit).`,
);
}
// [#9719] The unscoped-multi shape check, BEFORE the matched-row read:
Expand Down Expand Up @@ -16658,7 +16663,7 @@ export class ObjectQL implements IObjectQLEngine {
+ 'secret/password fields are masked on read and `internal: true` fields are omitted '
+ 'outright, so the value never leaves the engine on the generic data path; aggregating '
+ 'them (group-by, min/max, array_agg, …) would surface it. '
+ 'Refusing (fail-closed) — see ADR-0100 / #3171 / #7922.',
+ 'Refusing (fail-closed) — see ADR-0100.',
);
}
}
Expand Down Expand Up @@ -17422,7 +17427,8 @@ export class ObjectQL implements IObjectQLEngine {
this.logger.debug(
`${operation} of '${objectName}' inside transaction() is routed to datasource '${target}' while the ` +
`transaction is open on '${scope.datasource}' — executing it OUTSIDE the transaction, on its own ` +
'connection (ADR-0057 §3.6 system ledger, carved out by #5351). It commits independently and will ' +
'connection (ADR-0057 §3.6 system ledger — the one class carved out of the cross-datasource write ' +
'refusal). It commits independently and will ' +
'SURVIVE a rollback of this transaction: an audit/telemetry/event row may describe a write that was ' +
'undone. That is the decided direction of error for an append-only ledger — an extra reconcilable ' +
'row beats a missing row for a write that did commit. Said once per transaction per datasource.',
Expand Down
2 changes: 1 addition & 1 deletion packages/objectql/src/having-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ function unknownOperator(
return invalidFilterError(
`Unsupported operator '${op}' in \`${clause.root}\`. ${clause.semantics} and supports: ${supported}. `
+ `An unknown operator is refused rather than ignored — ignoring it would silently `
+ `return unfiltered aggregates (#4286, ADR-0078).`,
+ `return unfiltered aggregates (ADR-0078).`,
);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/objectql/src/hook-binder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export function bindHooksToEngine(
result.skipped += 1;
const reason =
'hook target names no object — an empty `object` is refused rather than widened to '
+ "the wildcard '*' (#4001). Name the object(s), or write `object: '*'` if firing on "
+ "the wildcard '*'. Name the object(s), or write `object: '*'` if firing on "
+ 'every object is the intent.';
result.errors.push({ hook: hook.name, reason });
if (opts.strict) {
Expand Down
10 changes: 6 additions & 4 deletions packages/objectql/src/hook-target-rebind-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,18 +167,20 @@ function buildMessage(info: {
? cleared
? ` The capability this used to have is RETIRED: clearing 'input.id' in a '${event}' handler ` +
`converted a by-id write into a PREDICATE write over the caller's 'where'. Since ADR-0058 ` +
`Addendum II (#5574 / #5846) the dispatch ladder is resolved BEFORE the before phase — the ` +
`Addendum II the dispatch ladder is resolved BEFORE the before phase — the ` +
`predicate path has to read its matched rows first, to build one context per row — so there ` +
`is no ladder left to re-enter.`
: ` The capability this used to have is RETIRED: rebinding 'input.id' in a '${event}' handler ` +
`moved the write to another row. The engine now resolves the target BEFORE the before phase ` +
`and computes the whole write against it — the pre-image, the 'readonlyWhen' locks, the ` +
`validation rules — so a by-id target is immutable once a handler runs, on BOTH verbs. ` +
`'delete()' honoured a rebind until #6752 by re-resolving the new target; that is retired ` +
`too, so one rule now covers both.`
`'delete()' used to honour a rebind by re-resolving the new target; that is retired ` +
`too, because a handler that silently redirects which row gets deleted is a trap — so ` +
`one rule now covers both.`
: path === 'unscoped-multi'
? ` This is the whole-operation dispatch an UNSCOPED predicate write delivers to a declared ` +
`shape guard (#9719, both write verbs since #9974): its 'id' is present-but-undefined ON ` +
`shape guard (one registered with 'dispatchUnscopedMultiWrite', on update and delete ` +
`alike): its 'id' is present-but-undefined ON ` +
`PURPOSE — there is no target row — and the dispatch ladder was resolved before any handler ` +
`ran, so binding 'input.id' here retargets nothing. It is refused rather than ignored, ` +
`because a silent no-op is the failure this contract exists to abolish.`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ describe('[#4551] dangling stored references are reported, never rewritten', ()
});
await auditDanglingReferences(dirty);
expect(dirty.warnings).toHaveLength(1);
expect(dirty.warnings[0][0]).toContain('#4551');
expect(dirty.warnings[0][0]).toContain('reported, never rewritten');
expect((dirty.warnings[0][1] as any).references).toEqual([
'sys_position_permission_set#ppr_1.permission_set_id → sys_permission_set#ps_gone',
]);
Expand Down Expand Up @@ -495,7 +495,7 @@ describe('[#4747] a run that was called off is not a finding about the data', ()
expect(out.unreadableObjects).toEqual([]);
// The real finding is still reported, and the summary line carries the
// incompleteness so the log cannot read as a finished run either.
const summary = port.warnings.find((w) => w[0].includes('#4551'));
const summary = port.warnings.find((w) => w[0].includes('reported, never rewritten'));
expect(summary).toBeDefined();
expect((summary![1] as any).aborted).toBe(true);
});
Expand Down Expand Up @@ -712,7 +712,7 @@ describe('[#4743] provenance references are audited, in their OWN bucket', () =>

const out = await auditDanglingReferences(port);

const summary = port.warnings.find((w) => w[0].includes('#4551'));
const summary = port.warnings.find((w) => w[0].includes('reported, never rewritten'));
expect(summary).toBeDefined();
const meta = summary![1] as Record<string, unknown>;
expect(meta.dangling).toBe(1);
Expand Down Expand Up @@ -956,7 +956,7 @@ describe('[#5718] objects a finite budget never reached are named, not dropped',
});
await auditDanglingReferences(loud, { maxRows: 1 });

const summary = loud.warnings.find((w) => w[0].includes('#4551'));
const summary = loud.warnings.find((w) => w[0].includes('reported, never rewritten'));
expect(summary).toBeDefined();
const meta = summary![1] as Record<string, unknown>;
// Itemised, not merely counted: object-scale, and a reader who has to act
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -733,7 +733,7 @@ export async function auditDanglingReferences(
// They ride along whenever the line fires for a real finding; the full
// report always carries them for a caller that came looking.
if (report.dangling.length || report.undetermined || report.unreadableObjects.length) {
port.warn?.('[integrity] stored references that resolve to nothing (#4551)', {
port.warn?.('[integrity] stored references that resolve to nothing — reported, never rewritten: a system-context write is exempt from the write-time reference check, so this audit is where such a reference surfaces', {
scanned: report.scanned,
dangling: report.dangling.length,
undetermined: report.undetermined,
Expand Down
2 changes: 1 addition & 1 deletion packages/objectql/src/lifecycle/lifecycle-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const lifecycleSettingsManifest = {
'Per-object window overrides: { "<object>": { "maxAge": "1y", "expireAfter": "30d" } }. ' +
'Duration literals: h/d/w/y. Tenant-scoped — a regulated tenant sets years while dev keeps days (ADR-0057 §3.2). ' +
'An override BELOW a retention floor a consumer registered (e.g. the job queue\'s dedup window) is rejected at ' +
'sweep time and logged at error — the declared window keeps running (#5195).',
'sweep time and logged at error — the declared window keeps running.',
},
{
type: 'json',
Expand Down
2 changes: 1 addition & 1 deletion packages/objectql/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,7 @@ export class ObjectQLPlugin implements Plugin {
});
this.subscribeMetadataRebind(ctx, protocolShim);
} else {
ctx.logger.info('registerProtocol=false — protocol assembly delegated to MetadataProtocolPlugin (ADR-0076 Step 2, #2462)');
ctx.logger.info('registerProtocol=false — protocol assembly delegated to MetadataProtocolPlugin (ADR-0076 Step 2)');
}

// ADR-0057: the platform-owned LifecycleService. Registered from the
Expand Down
4 changes: 2 additions & 2 deletions packages/objectql/src/readonly-strict-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ function buildRefusalMessage(
const head = `${operation === 'insert' ? 'Insert' : 'Update'} on '${object}' was REFUSED: `;
const tail =
`To let the strip happen and merely observe it, drop ` +
`strictReadonlyWrites and pass options.onFieldsDropped instead (#3407).`;
`strictReadonlyWrites and pass options.onFieldsDropped instead.`;

// Empty `drops` cannot happen on either throw site, but `every` on it is
// vacuously true, which lands on the historical wording — the safe default.
Expand All @@ -138,7 +138,7 @@ function buildRefusalMessage(
(operation === 'insert'
? `{ context: { isSystem: true } } — or, for a data migration reinstating legacy ` +
`values for a runtime-owned field (a record number), the historical-import ` +
`context { context: { preserveAudit: true } } (#3493). `
`context { context: { preserveAudit: true } }. `
: `{ context: { isSystem: true } } (this exempts statically 'readonly' fields, but NOT ` +
`fields locked by a TRUE 'readonlyWhen' predicate — those stay locked for every ` +
`API-boundary caller, isSystem included. A value DERIVED by a beforeUpdate hook is ` +
Expand Down
Loading
Loading