FlowSpec は信頼できる唯一の情報源です。SDKがこれを構築し、YAML parserがこれを構築し、compilerが正規化してハッシュ化し、Riderが実行し、emitterがそれをYAMLに戻します。システム内のすべてのものは、FlowSpecを生成するか消費します。ここを正しくすれば、残りはadapterになります(原則 T4)。

1. 1つのIRを使う理由

SDKとYAMLが異なる内部形状へコンパイルされると、type checker、scheduler、inspector、diff viewer、replication verifierなど、すべてのToolが2つのコードパスを必要とし、ドリフトします。正準形が1つであることは、次を意味します。

1つのvalidator / scheduler / inspector

すべてのToolが同じ正準形を読むため、コードパスは1つに保たれます。

安定したidentity

caching、pinning、replicationのためのdigest

Round-tripping

設計上のsurfaces間で往復変換が保証されます(T1)。

Forward-compatible migration

単一の schema_version によって実現します。

2. コンパイルパイプライン

1

Parse

どちらのsurfaceもraw AST(steps、expressions、bindings)を生成します。
2

Resolve

すべての use: は、既知の ToolContract を持つ、ピン留めされた name@version に解決されます。ピン留めされていないuse(search-pricesearch-price@^1)は具体的なversionに解決され、正確なversionへ書き換えられるため、FlowSpecは完全にピン留めされます。Resolutionは決定論的です。browserflow.lock が存在する場合、rangeはそこに記録された正確なversionおよびcontract digestへ解決されます。存在しない場合は、active registry内の最新のcompatible versionが選ばれ、lockへ書き戻されます。これが、CIで browserflow compile --check が安定したdigestを生成する理由です。解決を決めるのはwall-clock上のregistry stateではなく、lockです。
3

Type-check

各binding expressionは、生成元Stepのoutput schemaと、消費側Toolのinput schemaに対してチェックされます。reference cycle、unknown step、undeclared inputは、実行時ではなくここでerrorになります。
4

Canonicalize

§4 を参照してください。
5

Digest

§5 を参照してください。
compilerはpure functionです。compile(source, registry) → FlowSpec | Diagnostics[]。Tool contractを(場合によってはin-memoryの)Registryから読む以外のI/Oはありません。これにより、browserflow compile は高速でcache可能で、CIで安全に実行できます。

3. schema

import { z } from "zod";

export const FlowSpecSchema = z
  .object({
    schema_version: z.literal("1.0"),
    name: z.string().min(1),
    description: z.string().optional(),
    inputs: z.array(InputDeclSchema),
    steps: z.array(StepSchema), // topologically independent; order is for humans
    output: ExprSchema.optional(),
    /** Every tool this flow invokes — exact version + resolved contract digest
     * (`RequireSchema`, [01 §0](01-concepts.md#0-shared-schema-vocabulary)).
     * Derived, not authored; folded into the flow digest (§5). */
    requires: z.array(RequireSchema),
    /** sha256 of the canonical form (§4–5). Absent only mid-compile. */
    digest: z.string().optional(),
  })
  .strict();

export type FlowSpec = z.infer<typeof FlowSpecSchema>;
StepSchemaInputDeclSchemaExprSchema01 — Concepts で定義されています。FlowSpecはplain JSONであり、functionもclassも含まないため、serialize、diff、移動が容易です。

4. 正規化

同じことを意味する2つのFlowは、digestが一致し、replicationがdedupeできるように、byte-identicalな正準形を生成しなければなりません。Canonicalizationは、固定され文書化されたnormalizationです。
ルール詳細
Key orderすべてのobject keyを辞書順(UTF-8 code unit)にsortします。
Step orderStepsは安定したtopological key (depth, id) でsortされます。順序がschedulingに影響することはありません。順序は needs / referencesから導出されるため、sortは安全であり、diffを最小化します。
WhitespaceJSONは意味のないwhitespaceなしでserializeされます。encodingは1つの正準encoding(RFC 8785 JCS)です。
ExpressionsASTへparseされ、normal formで再出力されます。operatorの前後にsingle spaceを置き、parenthesesはprecedence上必要な場合だけ使い、string quotingをnormalizeします。${{ a+b }}${{ a + b }} は同じ形に畳み込まれます。
Defaults省略されたoptional fieldはmaterializeされません。absentはabsentを意味します。明示的に書かれたdefault valueと省略だけが異なる2つのFlowは、省略へnormalizeされます。
Versionsすべての use: は正確な name@x.y.z に書き換えられます。requires は、書き換えられたusesから { ref, contract_digest } のsortedかつdedupedされたlistとして再構築されます。
Input slotsYAMLの type: shorthandは完全なJSON-Schema slot({ "type": "string" })へ展開されます。secret は省略時に false がdefaultになります。
Dead noops効果がなくdownstream referenceも持たない noop stepはelideされるため(dead-noop elimination、§6a)、名残だけのbranch armがdigestに到達することはありません。
Numbers正準なnumber formatting(shortest round-trip decimal)を使います。
Canonicalizationはidempotentです。canon(canon(x)) == canon(x)。YAML emitterは正準形からemitするため、compile → emit → compile はfixed pointになり、Gitにcheck inされたFlowは最小で安定したdiffを持ちます(DX promise、原則 T1)。

5. digest

digest = "sha256:" + hex(sha256(JCS(flowspec_without_digest_field)))
  • digest field自体はhashから除外されます(値をそれ自身へhashすることはできません)。
  • JCS = JSON Canonicalization Scheme(RFC 8785)であり、§4 の後に適用されます。
  • digestはFlowの構造およびピン留めされた requires を対象にします。それぞれの requires は単なるversion stringではなく、解決済みToolのcontract digestを持ちます。そのため、Flow digestをピン留めすることはdependency closure全体をピン留めすることになります。bodyまたはcontractが変わったToolが再公開されると、同じversion numberであっても異なるcontract digestになり、したがって異なるFlow digestになります。Flow digestはself-certifyingです。version labelも併せて信頼する必要はありません。
digestはあらゆる場所でFlowのidentityです。cache key、Pack manifest内のpin、replication dedupe key、そして各Runに記録される flow_digest です(T3)。

6. referenceからgraphへ

schedulerはproseを見ることはありません。機械的に導出されたDAGを見ます。
1

referenceを収集

各Stepについて、その withwhenforeach expressionに現れる steps.<id> referenceの集合を収集します。
2

edgeを追加

それぞれについてedge referenced_id → this_id を追加します。明示的な needs があればunionします。
3

非循環を検査

結果はacyclicでなければなりません(compile timeにチェックされます)。incoming edgeがないStepはrootです。独立したStepはparallelに実行できます。
notifywhen でgateされているため、実行時にskipされることがあります。 parallelの例: ab は互いに独立しているため並列実行できます。両方が完了すると join が実行されます。 edgeはdata referenceから来るため、dependency graphは常に正直です。先に実行されたことを保証するedgeを作らずに、誤ってStepのoutputを読むことはできません。

6a. control-flow loweringとskip propagation

branchwhenforeach が唯一のcontrol constructであり、IRが保存するのはそのうち2つだけです。Stepごとの when predicateと、Stepごとの foreach です。authoring sugarは機械的にloweringされるため、FlowSpecにはschedulerが解釈すべき特別なcontrol nodeはありません。
  • branch(pred, \{ yes, no \}) は、その内部に含まれるStep上の when へloweringされます。yes: arm内の各Stepは when: pred を得ます(既存の when があればANDされます)。no: arm内の各Stepは when: !(pred) を得ます。FlowSpec内に branch nodeはありません。あるのは、それぞれgatedされたStepだけです。
  • noop(たとえば、何もしないbranchの no: arm)は、lowered graph内では実際のStepです。効果がなくかつdownstream referenceもない場合、canonicalizationによりelideされるため(dead-noop elimination、§4)、digestに到達することはありません。何かがそれをreferenceしている場合は保持されます。
Skip propagation。 Stepは、その when がfalseに評価されると skipped になります。skipは1つのruleによってdata edge沿いに伝播します。このruleは静的に決まり、raceによって決まることはありません。
  • X がskipされた場所で steps.X.output をreferenceするStepは、それ自体もskippedになります。ただし、そのようなreferenceがすべてfallback(default(steps.X.output, …) または steps.X.output ?? …)でguardされている場合は例外で、その場合referenceは null を返し、Stepは実行されます。
  • output も同じように評価されます。条件付きでskipされたStepをfallbackなしで読むprojectionはcompile timeに拒否されます(unguarded_skip§7)。そのため、Flowのoutputが黙って null になることはありません。
これによりbranchはtotalになります。どのinputに対しても、すべてのStepは succeededskippedfailed のちょうど1つで終了し、各downstream Stepが見るものは、どのconcurrent Stepがたまたま先にcommitしたかではなく、compilerによって固定されます。

7. validationとdiagnostics

compile-time diagnosticsは正確で、source-mappedされています(YAMLではline/column、SDKではbuilder stack frame)。catalogueには次が含まれます。
Diagnostic発生条件
unknown_tool / unresolved_versionuse: がRegistry内で解決されません。
type_mismatchbindingのtypeがToolのinput schemaを満たしていません。
unknown_referencex がStepではない steps.x、またはoutput schemaが持たないpathです。
cyclereference graphがacyclicではなく、cycle pathが名指しされます。
secret_leaksecret: referenceがoperatorまたはfunctionで変換されている、secrets を宣言していないToolにbindされている、または output へ流れています。Secretはopaqueなwhole-bindingのみです(taint rule、01 §7)。
unguarded_skipbindingが、条件付きでskipされるStep Xsteps.X.outputdefault(…) / ?? fallbackなしで読んでいます(§6a)。
unbound_input必須のTool inputにbindingもdefaultもありません。
Diagnosticsは投げられる文字列ではなくdata(Diagnostic[])なので、CLI、LSP、UIは同じ方法でそれらをrenderします。

8. versioningとmigration

schema_version はdomain schemaとまったく同じliteral("1.0")です。IRは3つのruleのもとで進化します。
  • Schemaは .strict() なので、Riderが認識しないfieldを黙って無視することはありません。したがってforward-compatibilityはlenient parsingではなく、明示的なversion negotiationによって実現されます。Riderは自身がsupportする schema_version rangeを宣言します。
  • Additive change(新しいoptional field)はminorを上げます。1.0 → 1.1。Riderは、同じmajor内で自身が知る最新minor以下の schema_version を持つ任意のFlowSpecを実行します。Riderがsupportするより高いminorが刻印されたFlowSpecは、明確に拒否されます(原則 T7)。coerceされることも、partial parseされることもありません。前進する道は、より新しいRiderを実行すれば常にあります。
  • Breaking changeはmajorを上げ、保存済みFlowSpecを書き換える migrate(1.x → 2.0) codemodとともにshipされます。Migrationは明示的で、可能な場合はreversibleです。
FlowSpecが黙ってcoerceされることは決してありませんschema_version を尊重できないRiderは、推測するのではなく明確に失敗します。

9. binary formatではなくJSONである理由

可読性が勝ちます(原則 T5)。FlowSpecは、PRで読まれ、grepされ、いざという時には手で編集され、人間によってdiffされることを意図しています。正準形は十分compactなのでbinary encodingとの差によるcostは無視でき、debuggabilityにはbyte数をはるかに上回る価値があります。digestはreadabilityを諦めることなく、binary formatが与えてくれたはずのintegrityを与えてくれます。

SDKとYAMLオーサリング

どちらもこのFlowSpecへcompile downする2つのsurfaceです。

実行と再現性

RiderがFlowSpecをRunへ変える場所です。