Skip to content

Commit afa6540

Browse files
committed
Merge commit '31ed067639' into claude/issue-20594-client-dead-citations
2 parents 5dc93b6 + 31ed067 commit afa6540

33 files changed

Lines changed: 717 additions & 182 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/service-datasource': patch
3+
---
4+
5+
Provenance comments in `service-datasource` were re-anchored
6+
7+
Comment and docblock lines under `src/` that cited tracker numbers which no
8+
longer resolve on GitHub now cite the commit in this repository's history that
9+
decided the matter, and say in their own words what was decided. Comments
10+
only: no type, schema, export, log or refusal text, or runtime behaviour changes.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/lint': minor
3+
'@objectstack/metadata-protocol': minor
4+
---
5+
6+
The runtime metadata publish gate refuses an `api` flow with no per-flow secret, and reads a secret the flow read path withheld as present (#20611).
7+
8+
Clause-②: yes (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable changes spelling or type: `packages/spec` is untouched, and the start node `config` stays the open record it was. What changes is that the runtime metadata write door now refuses one authored shape at publish: an `api`-bound flow whose start node carries no usable `config.secret`. `objectstack migrate meta` could not rewrite that shape even in principle, because the missing value is a shared secret only the author and the sending system can supply. Rows at rest are not judged or rewritten; the automation engine has refused to register such a flow since 17.5.0, and that load path's disposition is recorded in its own published changelog entry. -->
11+
12+
**BREAKING** — an accept-set narrowing on the runtime metadata write door,
13+
shipped as `minor` under the launch-window convention (`check-changeset-no-major`
14+
refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087
15+
disposition above, not by the level). An `active` save through `/meta` of an
16+
`api`-bound flow whose start node carries no usable `config.secret` (a
17+
`PUT /api/v1/meta/flow/:name`, or the publish of such a draft) used to be stored;
18+
the automation engine then refused to register it (`400` on the `/automation`
19+
doors, a skip with a warning at boot). It is now refused at the save with
20+
`422 INVALID_METADATA`, the issue naming `flow-api-trigger-secret-missing` at the
21+
start node's `config.secret`, and nothing is stored. A draft save is still
22+
accepted; its publish is refused the same way.
23+
**One-line fix:** set a non-blank `config.secret` on the flow's start node — or,
24+
for a flow that is only ever started explicitly, declare `type: 'autolaunched'`
25+
with no `triggerType: 'api'`.
26+
27+
**What does not change: a signed flow's round trip.** Every served flow definition withholds the start node's `config.secret`, so a body saved back after a read arrives without it, and the save restores the stored secret only after every gate has run, so that no gate handles a restored credential. The gate is now told WHERE the save will restore a credential from the stored row: those positions only, never the values. `flow-api-trigger-secret-missing` reads a secret that was withheld and is stored as present, and one that is absent and not stored as missing. So a GET → edit → PUT of a signed flow, and the first save of a code-authored flow whose secret is in the app's source, keep passing and keep their secret. An explicit empty `config.secret` is the author's own value and is refused as blank.
28+
29+
`@objectstack/lint`:
30+
31+
- `validateFlowApiTriggerSecret` now runs on the runtime publish gate too (`surfaces` `['cli', 'runtime-publish']`, `runtimeTypes: ['flow']`). Its `surfaceReason` is gone.
32+
- `AuthoringRuleContext` gains an optional `restoredCredentialPaths`: a `ReadonlySet<string>` of stack-relative positions in the rules' own finding-path spelling (`flows[0].nodes[1].config.secret`). Only the runtime publish gate sets it; `runAuthoringRules` never forwards it, so `os validate`, `os build` and `os lint` judge the author's own values as before.
33+
- `runRuntimeAuthoringRules` accepts an optional `restoredCredentialPaths`: item-relative dotted positions in the `@objectstack/spec/kernel` redactor registry's `redactedKeys` spelling (`nodes.1.config.secret`). The gate re-spells them against the written item's place in its snapshot.
34+
- `validateFlowApiTriggerSecret(stack, options?)` accepts an optional `{ restoredCredentialPaths }`, and treats a listed start-node secret position as present.
35+
36+
`@objectstack/metadata-protocol`: `saveMetaItem` hands the runtime authoring gate the positions its own credential carry-forward will fill, computed from the same stored body. This costs one indexed `sys_metadata` read on an `active` save of a type with a registered redactor (`datasource`, and `flow` where the automation plugin registers one), and nothing for any other type or for a draft save. The carry-forward itself is unchanged and still runs after every gate. The draft→active promotion judges the stored draft row, which already holds what that draft's save carried forward, so it needs no such positions.

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 44 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,28 @@ export interface AuthoringRuleContext {
337337
* this input or does not judge.
338338
*/
339339
judgeFilter?: IObjectQLEngine['judgeFilter'];
340+
/**
341+
* [#20611] The credential positions of the WRITTEN item that the write path
342+
* restores from the stored row before it persists the item — stack-relative,
343+
* in the rules' own finding-path spelling (`flows[0].nodes[1].config.secret`).
344+
*
345+
* Set only by the runtime publish gate (`runtime-gate.ts`), from what its host
346+
* states; ABSENT on the three CLI commands, whose stacks carry the author's
347+
* own values. It exists because the read path withholds a credential from
348+
* every served definition (a flow's start-node `config.secret`), so a body
349+
* saved back after a read arrives WITHOUT it, and the host's carry-forward
350+
* puts the stored value back only after every gate has run — on purpose, so
351+
* that no gate handles a restored credential. A rule therefore cannot tell a
352+
* withheld credential from a missing one by reading the body, and this set is
353+
* how it is told: a path listed here is WITHHELD AND STORED, and a rule
354+
* judging whether a credential is present reads it as present. A path not
355+
* listed is judged on the body as sent, so a credential that is absent and
356+
* not stored is missing.
357+
*
358+
* ⛔ Positions only, never values: the gate never sees a restored credential.
359+
* Read by `validateFlowApiTriggerSecret` only.
360+
*/
361+
restoredCredentialPaths?: ReadonlySet<string>;
340362
}
341363

342364
export interface AuthoringRule {
@@ -1113,7 +1135,8 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
11131135
// routes nowhere, silently demoting it to a manual flow. #20553 made it five: an
11141136
// `api`-bound flow with no usable `config.secret`, which the engine's own
11151137
// `registerFlow` refuses (ADR-0041) — on its OWN entry below
1116-
// (`validateFlowApiTriggerSecret`), because it is CLI-only for now. None of those verdicts
1138+
// (`validateFlowApiTriggerSecret`), because it reads a context input this entry
1139+
// has no use for (#20611). None of those verdicts
11171140
// can be changed by installing a package, so there is no reading under which
11181141
// the flow fires. `flow-trigger-unknown-object` deliberately stayed `warning`
11191142
// (the object may come from another installed package — a hedge this rule
@@ -1144,33 +1167,32 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
11441167
run: (stack) => validateFlowTriggerReadiness(stack),
11451168
},
11461169
// #20553 — `flow-api-trigger-secret-missing`, split out of the entry above as
1147-
// its own exported rule (the `validateSecurityRoleWord` precedent: one rule id
1148-
// sits on ONE side of the runtime wall). Same family, same `error`, all three
1149-
// commands — but NOT the runtime publish gate yet, and #20611 is the card that
1150-
// moves it across. The flow read path withholds `config.secret` from every
1151-
// served definition (#20552) and `saveMetaItem` restores the stored secret only
1152-
// just before the put, AFTER this table has judged the body the caller sent —
1153-
// so on the gate, a signed flow's ordinary GET → edit → PUT reads as
1154-
// secretless. Measured on `825c33ff9f`: with this id on the gate, the two
1155-
// round-trip pins in `protocol.metadata-redaction.test.ts` fail; off it, 26/26.
1156-
// The `/meta` door therefore keeps its pre-rule behaviour (it stores a
1157-
// secretless flow, and the engine refuses it at registration) until the gate
1158-
// judges the carried-forward body — then this entry becomes `CLI_AND_RUNTIME`
1159-
// with `runtimeTypes: ['flow']`, like the one above.
1170+
// its own exported rule. Same family, same `error`, all three commands.
1171+
//
1172+
// #20611 — and the runtime publish gate too. The flow read path withholds
1173+
// `config.secret` from every served definition (#20552), and `saveMetaItem`
1174+
// restores the stored secret only just before the put, AFTER this table has
1175+
// judged the body the caller sent — deliberately, so no gate handles a
1176+
// restored credential. So the gate is handed the POSITIONS that restore will
1177+
// fill (`AuthoringRuleContext.restoredCredentialPaths`), and the rule reads a
1178+
// withheld-and-stored secret as present: a signed flow's ordinary
1179+
// GET → edit → PUT passes, and a secretless `api` flow is refused at `/meta`
1180+
// with this id instead of being stored for the engine to refuse at
1181+
// registration.
11601182
{
11611183
name: 'validateFlowApiTriggerSecret',
11621184
tier: 'gating',
11631185
input: 'normalized',
11641186
commands: ALL,
11651187
source: 'packages/lint/src/validate-flow-trigger-readiness.ts',
1166-
surfaces: CLI_ONLY,
1167-
surfaceReason:
1168-
'Not yet runtime-safe: the publish gate judges a /meta save BEFORE saveMetaItem restores the ' +
1169-
'inbound-hook secret the flow read path withholds, so a signed api flow\'s ordinary GET, edit, PUT ' +
1170-
'round trip reaches this rule secretless and would be refused. It crosses when the gate judges the ' +
1171-
'carried-forward body (the seam follow-up named in the comment above); until then the engine\'s ' +
1172-
'registerFlow refusal is what a secretless flow saved through /meta meets.',
1173-
run: (stack) => validateFlowApiTriggerSecret(stack),
1188+
// Runtime publish gate (#20611): judged on the per-write snapshot like the
1189+
// entry above, with the host's restored-credential positions read as
1190+
// present. The CLI never sets that input, so there the author's own
1191+
// `config.secret` is the whole answer.
1192+
surfaces: CLI_AND_RUNTIME,
1193+
runtimeTypes: ['flow'],
1194+
run: (stack, ctx) =>
1195+
validateFlowApiTriggerSecret(stack, { restoredCredentialPaths: ctx.restoredCredentialPaths }),
11741196
},
11751197
// ADR-0090 D3 fallout — an approval `{ type: 'role' }` resolves against the
11761198
// better-auth org-membership tier, not positions, so a position name authored

‎packages/lint/src/runtime-gate.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -832,6 +832,49 @@ export function nameKeyFindingPath(path: string, candidate: AnyRec): string {
832832
return `${stackKey}.${name}${rest}`;
833833
}
834834

835+
/**
836+
* [#20611] The host's restored-credential positions, re-spelled from the
837+
* redactor registry's item-relative dotted form (`nodes.1.config.secret`) into
838+
* the finding-path form the rules emit and compare against, anchored at the
839+
* written item's place in `candidate` (`flows[0].nodes[1].config.secret`).
840+
*
841+
* The written item is the LAST member of its collection in the candidate —
842+
* {@link buildRuntimeWriteSnapshots} appends it — so that index is the anchor.
843+
* Each segment is spelled `[n]` exactly where the item holds an array at that
844+
* point of the walk and `.key` everywhere else: read off the item, never
845+
* guessed from the segment's digits, so a record key that happens to be
846+
* numeric keeps its key spelling. A path that walks off the item keeps the key
847+
* spelling from there on; it names no position any rule reports, so it
848+
* excuses nothing.
849+
*/
850+
function restoredCredentialStackPaths(
851+
candidate: AnyRec,
852+
type: string,
853+
dottedPaths: readonly string[],
854+
): ReadonlySet<string> {
855+
const stackKey = stackKeyForType(type);
856+
const collection = stackKey ? candidate[stackKey] : undefined;
857+
if (!stackKey || !Array.isArray(collection) || collection.length === 0) return new Set();
858+
const index = collection.length - 1;
859+
const item: unknown = collection[index];
860+
const out = new Set<string>();
861+
for (const dotted of dottedPaths) {
862+
let path = `${stackKey}[${index}]`;
863+
let node: unknown = item;
864+
for (const segment of dotted.split('.')) {
865+
if (Array.isArray(node) && /^(0|[1-9][0-9]*)$/.test(segment)) {
866+
path += `[${segment}]`;
867+
node = node[Number(segment)];
868+
} else {
869+
path += `.${segment}`;
870+
node = node !== null && typeof node === 'object' ? (node as AnyRec)[segment] : undefined;
871+
}
872+
}
873+
out.add(path);
874+
}
875+
return out;
876+
}
877+
835878
function runRules(
836879
rules: readonly AuthoringRule[],
837880
stack: AnyRec,
@@ -894,6 +937,20 @@ export function runRuntimeAuthoringRules(args: {
894937
* it skip the engine's judgement and answer as they did without it.
895938
*/
896939
judgeFilter?: IObjectQLEngine['judgeFilter'];
940+
/**
941+
* [#20611] The positions in `item` at which the host's write path restores a
942+
* credential from the stored row before it persists the item — dotted and
943+
* item-relative, the `@objectstack/spec/kernel` redactor registry's
944+
* `redactedKeys` spelling (`nodes.1.config.secret`). The read path withholds
945+
* those credentials, so a body saved back after a read arrives without them,
946+
* and the host restores them only after this gate has run.
947+
*
948+
* Handed to the rules as `AuthoringRuleContext.restoredCredentialPaths`,
949+
* translated into the candidate snapshot's finding-path spelling; see
950+
* {@link restoredCredentialStackPaths}. Omitted, every position is judged on
951+
* the body as sent. ⛔ Positions only: no credential value reaches this gate.
952+
*/
953+
restoredCredentialPaths?: readonly string[];
897954
}): RuntimeGateResult {
898955
const rules = runtimeAuthoringRulesFor(args.type);
899956
const empty: RuntimeGateResult = { errors: [], advisories: [], rulesRun: [] };
@@ -921,6 +978,19 @@ export function runRuntimeAuthoringRules(args: {
921978
sduiManifest: args.sduiManifest,
922979
runtimeWriteType: args.type,
923980
judgeFilter: args.judgeFilter,
981+
// [#20611] Spelled against the CANDIDATE, the one snapshot that holds the
982+
// written item. The baseline pass shares the set and cannot match it: the
983+
// item is not in the baseline, and every other entry sits at an index the
984+
// item does not.
985+
...(args.restoredCredentialPaths !== undefined && args.restoredCredentialPaths.length > 0
986+
? {
987+
restoredCredentialPaths: restoredCredentialStackPaths(
988+
snapshots.candidate,
989+
args.type,
990+
args.restoredCredentialPaths,
991+
),
992+
}
993+
: {}),
924994
};
925995
const before = new Set(runRules(rules, snapshots.baseline, ctx).map(fingerprint));
926996
const added = runRules(rules, snapshots.candidate, ctx)

0 commit comments

Comments
 (0)