Skip to content

feat: contract fragments, drafts 21-29 (text refs, siblings, pointers, one composition) and 0.25.0 - #760

Merged
fas89 merged 59 commits into
mainfrom
feat/fragment-refs
Oct 28, 2026
Merged

fas89 merged 59 commits into
mainfrom
feat/fragment-refs

Conversation

@fas89

@fas89 fas89 commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Contract fragments: the $ref work of drafts 21–29, and the 0.25.0 release notes.

What changes

  • A $ref can name a text file (.sql, .md, …) and becomes its text; a fragment may be any JSON or YAML value.
  • Keys beside a $ref are merged over its target (RFC 7386), and same-document #/ pointers resolve against the composed contract.
  • One composition for every command and the library: the fragments, then the overlay with its own refs, then the pointers. Nothing is dropped and no $ref is left.
  • Validation errors name the fragment file and line. fluid validate --fragment KIND FILE checks one fragment, and an authoring schema lets editors check fragment-first contracts.
  • fluid docs reads composed contracts and the 0.7.x shapes.
  • fluid split --parts chooses what moves, and keeps the root's comments.
  • ref_root in fluid.workspace.yaml widens the ref root once for a workspace.
  • Stricter, bounded file handling while a contract is composed.

Details and upgrade notes are in CHANGELOG.md under [0.25.0]. Some inputs 0.24.0 accepted are now refused; the upgrade notes list them.

Compatibility

  • A contract without $ref loads, bundles and exports byte-identically to 0.24.0.
  • A contract split into fragments bundles byte-identically to the same contract written flat. The default fluid split output stays readable by 0.24.0.
  • Library API 1.2.

Testing

  • New scenario suite under tests/fragments (about 1,900 tests).
  • The full suite is green. The only local failures are environmental and also fail on main.
  • ruff, black, import-linter, the strict mypy hotspots, the perf budgets and the secret scan are clean.

Smaller remaining items are tracked separately.

fas89 added 30 commits October 28, 2026 07:06
…fragment root

A $ref with sibling keys was not a ref at all: _is_ref_node required $ref to
be the only key, so {$ref: shared.yaml#/order_id, description: ...} stayed a
literal key and its target was never read. A .sql or .md target failed with
"YAML root must be an object/dict", or, for SQL holding ': ', with a raw
PyYAML error, or loaded silently as a mapping. A YAML fragment whose root is
a list was refused while the same list as .json loaded.

fluid_build/contract_refs.py now holds the resolver: one step for every ref,
read the target, apply its pointer, merge the siblings.

- Siblings are a JSON Merge Patch (RFC 7386) over a copy of the target, as
  json-schema-ref-parser's extended $ref does: objects merge, lists and
  scalars replace, null removes a key. A target that is not an object takes
  no siblings, and says so.
- A .sql, .md, .markdown, .txt, .py, .scala or .r target is its UTF-8 text,
  never parsed. A pointer into one is refused.
- A fragment may have any root; the root contract and overlays stay objects.
- Confinement, the system-path deny list, cycle detection and the depth limit
  apply to every ref as before, refs inside siblings included. A new cap stops
  refs that double the contract every step (the billion laughs of $ref).
- Pointers decode RFC 6901 escapes (~0, ~1).

loader.py keeps its names as wrappers over the module. The contract API
reads LoadedContract.files from the resolver's own trace instead of a second
walker that could not read a text target or see a sibling ref.

Drafts 21, 23 (siblings) and 24.
…efs, pointers resolve, nothing is dropped

An overlay was parsed raw and merged with its $ref unresolved. Then two
loaders disagreed. load_contract_with_overlay, behind validate, plan, apply,
generate standard and the library, saw a $ref left in the merged contract
and silently re-read the base, so `--env prod` planned the dev topic with
exit 0. materialize_contract, behind bundle and generate artifacts, kept the
literal $ref. Any $ref the resolver left behind (a same-document pointer) did
the same, so one leftover pointer dropped every overlay on the CLI path. And
generate artifacts without --env read a split contract's raw root.

loader.compose_contract is now the one composition every entry point reads:

1. the contract's file refs (contract_refs);
2. the overlay's own refs, relative to the overlay file and confined to the
   same root (FLUID_REF_ROOT is consulted when only the overlay names a
   file), then the merge, so a keyed 0.7.6 merge sees the ids the overlay's
   fragments name;
3. same-document #/ pointers, resolved against that result. A pointer names a
   place in the composed contract whichever file it is written in, so
   splitting a contract never changes it, and an overlay that changes a
   shared value reaches every pointer to it (late binding, as Jsonnet's
   self). Pointers may cross other refs; cycles and pointers that name
   nothing fail, naming the file and pointer they were written at.

No $ref survives: one that cannot be resolved fails every command. The
auto-bundle fallback is gone, and with it the overlay drop. bundle and
generate artifacts call the same composition; generate artifacts composes a
contract holding a $ref even without --env.

The contract API composes the same way and no longer mirrors the drop:
LoadedContract.overlay and .files come from the composition (the overlay's
fragments included), unresolved_refs is empty for every file load, and the
in-memory forms take overlay_dir= for the overlay's own refs (api 1.2).

docs/contract-refs.md and docs/CONTRACT_LOADING_API.md described the old
rules; they now describe these.

Drafts 22 and 23 (same-document pointers). They land together: 22's "a
surviving $ref fails every command" is 23's rule.
…nd line

Validation reported the path in the composed document only, so an error in
fragments/exposes/orders.yaml read "exposes[0].contract.schema[1].type: 42
is not valid ..." and the author mapped exposes[0] to a file by hand. Now:

    fragments/exposes/orders.yaml:14 (exposes[0].contract.schema[1].type): 42 is not valid ...

The composition records, in a side table keyed by path in the composed
contract, which file and which pointer inside it each part came from: each
fragment where it landed, the keys written beside a $ref, the overlay
(keyed-list entries at the index they landed on, matched by id), and
#/ pointers as aliases of their target. The contract stays plain dicts and
lists. A line is read only when an error needs one, from PyYAML compose
marks (no new dependency; the same size, alias and depth caps as the
loader's parse, via util.safe_yaml.compose_yaml_safe).

A value is located at the latest file that holds it. An unexpected key is
located at the key itself; a missing one at the object's definition, not at
an overlay or siblings that only merged other keys into it. Errors in the
root contract read as before. Extension validator errors name the fragment
of the block they are about.

The library gets the same answer: LoadedContract.origin_of(path) returns the
file and line a value was written in (api 1.2).

Draft 25.
…efs read regular files only

generate artifacts without --env read the raw root of a contract, so a split
contract failed ("FLUID expose has no usable name") or exported a literal
$ref, and reference-only detection never saw a build kept in a fragment.
generate ci's reference-only and engine detection read the raw root too.
Both now read the composed contract. A contract with no $ref is still read
exactly as before, so its artifacts are unchanged; one split into fragments
now gets byte-identical artifacts to the same contract written flat, with or
without --env. (An earlier commit on this branch routed the no-env case
through a temporary bundle; that path writes a differently ordered
contract, so it is reverted here in favour of composing in the reader.)

Hardening of the resolver, found by probing the scenario catalog:
- A target must be a regular file: a directory, FIFO or device is refused
  before it is read (a FIFO or /dev/zero blocked forever).
- /proc, /sys and /dev stay blocked whatever the root: their files report
  size 0, so the text cap could not stop /proc/self/environ.
- A file with no known suffix keeps the old rule: JSON, or a YAML object.
  Otherwise a dotfile such as .env would compose as a YAML string.
- A fragment that contains itself through a YAML alias is refused instead of
  overflowing the stack; nesting past Python's recursion limit is a ref
  error, not a crash.
- The expansion cap counts key characters and every copy an alias makes.
- A list index must be ASCII digits; the depth error names its ref; an
  overlay that composes to a list is refused.
fluid docs parsed each contract with a plain YAML load: a contract written by
fluid split listed its port as expose-0, columns were read from
exposes[].schema (no 0.7.x contract has them there, so every port showed "No
schema columns defined."), a 0.7.6 description object aborted the whole run
with "'dict' object has no attribute 'replace'" after truncating the page to
0 bytes, and Nullable/PII read keys the schema rejects.

- Contracts load through the loader fluid validate uses, refs resolved and
  YAML guarded. One that fails to load gets an index row naming the error
  and no page; the rest of the catalog is written.
- Ports are named by exposeId; columns come from contract.schema (a legacy
  top-level schema still reads); Nullable follows `required`, PII follows
  `sensitivity` (pii, phi), with the legacy keys still honoured.
- A description object renders purpose, usage and limitations; the owner
  object as team (email); domain and tags are read from the root first;
  consumes show productId/exposeId.
- A page is rendered before its file is opened, and two contracts with one
  id get a page each (-2) instead of overwriting one.

index.json keeps its keys and value types. Draft 27.
…ontract

fluid split always moved the same four sections, rewrote the root with
yaml.dump (every comment lost, quotes and flow style rewritten), wrote YAML
into a .json root, overwrote one fragment with another when two ids made the
same file name, and on a second run wrapped its own $refs into new fragments
until the contract no longer loaded.

- --parts chooses what moves: sovereignty, accessPolicy, builds, exposes
  (the default, as before), sql (a build's properties.sql to sql/<id>.sql,
  .py/.scala/.r by its language), quality and schema (an expose's lists),
  extensions or extensions.<name>, description (texts to docs/<part>.md).
  A part inside another is referenced from the file it ends up in
  (fragments/builds/x.yaml holds `sql: {$ref: ../../sql/x.sql}`).
- A YAML root is spliced, not re-dumped: every byte outside a moved part is
  kept, comments included, and each part takes its own text, comments and
  block scalars included, to its fragment. PyYAML compose marks give the
  spans; a block node's end mark runs over trailing comments, so a span ends
  at the node's own last character. No new dependency (ruamel is optional
  here, and loses between-item and end-of-file comments). A root with YAML
  aliases, or a JSON root, is split as data instead; a JSON root stays JSON.
- A part that is already a $ref is left alone, so a second run changes
  nothing; a file that exists is never written over, and two parts bound for
  one file get -2, -3. Text a file cannot give back exactly (a \r, a leading
  BOM) stays inline. A CRLF root keeps its line ends.
- Before it ends, split composes the result and compares it with the
  original; if they differ, every file it wrote is taken back.

The default layout is unchanged: the same files, refs and headers, and the
dict-level splitter fluid forge writes through produces exactly what it did.
Every shipped example (24) composes to the same document after a split, and
the v0.24.0 loader reads the default split output to the same documents.

Draft 28.
…s own tree

A workspace that keeps fragments its products share (a team, glossary
terms) outside each product's folder needed FLUID_REF_ROOT in every process
that loads one of them: CI, the editor, every tool on the library. Now the
workspace declares it once:

    # fluid.workspace.yaml
    ref_root: .          # refRoot is read too

found by walking up from the contract, relative to that file, and only read
for a contract that names another file.

A project file may not widen confinement past its own tree: that is why a
project .env cannot set FLUID_REF_ROOT (GHSA-58px-v64p-6ppm). So the root is
used only when it is a directory at or below the workspace file's own,
contains the contract, and passes the vetting the DuckDB sandbox already
gives a workspace grant: not the filesystem root, a home or a temp root, and
not above the repository the contract is in (a fluid.workspace.yaml in ~/src
would otherwise reach every clone). Anything else is ignored with a WARNING
once per file, and the contract keeps its own directory. An operator turns
the setting off with FLUID_IGNORE_WORKSPACE_REF_ROOT, which a project .env
cannot set.

Precedence: ref_root= > FLUID_REF_ROOT > the workspace > the contract's
directory. An ignored FLUID_REF_ROOT falls back to the root the contract gets
without it, the workspace's when it has one.

Draft 29.
…luid validate --fragment

An editor checking a contract written by fluid split against the 0.7.6
schema reported every fragment: "Additional properties are not allowed
('$ref' was unexpected)", and the four required keys of an expose. A
fragment could not be checked on its own: fluid validate read it as a
contract, fell back to schema 0.7.5, then 0.7.4.

- fluid_build/schemas/fluid-authoring-schema-0.7.6.json is the canonical
  0.7.6 schema with a {$ref} (siblings included) accepted at every value
  position, wrapped the way the OpenAPI 3.1 meta-schema admits a Reference
  Object (if {type: object, required: [$ref]} then fragmentRef else the
  canonical subschema), so every other value keeps the canonical checks and
  messages. Generated by python -m fluid_build.schema_authoring; a test
  fails when it drifts. Its name stays out of the fluid-schema-*.json glob
  that version discovery, the tests and the forge_docs generator read.
- The canonical schemas are unchanged: commands validate the composed
  contract, and Command Center generates its models from them.
- fluid validate --fragment KIND FILE validates one fragment against the
  $defs entry it stands for (expose, build, column, sovereignty, ..., or
  quality / schema for an expose's lists), its own refs resolved under the
  root its contract would have, the version from --schema-version or the
  nearest contract.fluid.yaml above it. Its errors read as the whole
  contract's do: fragments/exposes/orders.yaml:14 (contract.schema[1].type).

Draft 26.
…kept in fragments

Read raw, a fragment-first contract reached the tools with {$ref: ...} where
its builds and exposes are, so validate_contract reported errors fluid
validate does not, and an empty file validated nothing. The path form now
loads the contract the way every command does: composed, fragments confined
to the contract's directory unless widened.
…g every command

compose_contract asks iter_external_refs whether the root names another
file before composing it, and that walk visited every path: on a root such
as `extensions: &e {self: *e}` it never ended, so fluid validate, plan,
the library and the MCP tools hung on one line of input. origin/main failed
on it at once ("maximum recursion depth exceeded").

iter_external_refs now visits each container once, as find_ref_node does,
and the composition reports the cycle: "the document contains itself
through a YAML alias".
- fragment_sites (cyclomatic complexity 72) is one generator per part:
  sections, entries (with a build's SQL and an expose's lists), extensions,
  description; same sites, same order.
- load_yaml_safe and compose_yaml_safe share the checked compose step.
- The two ref-root warn-once registries share one first-time check.
- A schema error's location is built by one helper (_error_path), and a
  docs column row by another (_column_row_html).
- Removed describe_fragment_layout and loader._pointer_parts, which nothing
  calls since split and the resolver stopped using them.

Found by ripwire --quality-delta over the branch. No behaviour change.
ContractOrigins._locate (cyclomatic complexity 31) asks each record what it
wrote through _from_entry, and _line walks the YAML node tree a step at a
time through _node_child; fragment_sites' per-entry branch is _item_sites.
Same answers: the provenance and split suites pass unchanged.
Trimmed this branch's edits to the sentences the change made false: a ref
may name any value or a text file, keys beside $ref merge, same-document
refs resolve, an overlay's refs resolve, nothing is left, and the workspace
can widen the root. The authoring schema, --fragment and the workspace
details go to forge_docs, where the user docs live.
… per area

1,871 tests over the 394 scenarios of the fragment work: targets, siblings
and encodings; pointers, cycles and the expansion budget; confinement;
overlays and the one loader; provenance; fluid split; docs, the authoring
schema and validate --fragment; the workspace ref_root and the
compatibility gate. A scenario the code does not meet yet is a strict
xfail naming its finding, so the suite turns green as each is fixed.

describe_fragment_layout is back (0.24.0 had it, and the split
compatibility tests import it): split --dry-run lists through it.
…xplained

What was wrong:
- A ref_root the file system cannot stat (a name longer than it allows, or a
  path under a directory the process may not enter) raised a bare OSError out
  of the loader on Python 3.10 to 3.13: Path.is_dir() after resolve() was
  unguarded. It failed even a contract whose refs all stay in its own
  directory, and a valid FLUID_REF_ROOT did not help, because the workspace
  file was read before the variable was looked at. (L-21)
- A fluid.workspace.yaml that cannot be parsed (a list root, invalid YAML, an
  alias bomb, over the 5 MiB cap) was swallowed without a word. (L-15)
- When a workspace ref_root was refused, the escape error never said so. Its
  hint told the author to declare the ref_root they had just declared, and
  the one WARNING per process never reaches a service's caller. (L-03)

What is true now:
- An unstattable ref_root is ignored with the workspace_ref_root_ignored
  WARNING, like any other unusable value; the contract keeps its own
  directory.
- The workspace file is read only when its ref_root would be used: with no
  ref_root= and no FLUID_REF_ROOT, or with a FLUID_REF_ROOT that is ignored.
- A workspace file that cannot be parsed is ignored with a WARNING that names
  the file and why. A YAML syntax error is placed (line, column), never
  quoted.
- Every escape error of a contract whose workspace ref_root was ignored says
  so, why, and which file to fix; when FLUID_REF_ROOT was ignored too, it says
  both. With FLUID_IGNORE_WORKSPACE_REF_ROOT set, the hint says the switch is
  on instead of asking for a ref_root.
- A value from the workspace file is shown cut to 200 characters: the file
  may hold megabytes there, and every escape error now repeats the reason.

The strict xfails of L-03, L-15 (the four malformed files) and L-21 are
removed; their tests pass and pin the messages.
…ommand reads it

validate's composition rule looks up each consumed product's productType in
an index of the workspace's contracts, and it built that index from the raw
root of every *.fluid.yaml. An upstream whose metadata lives in a $ref
fragment had no productType there, so an ADP consuming a CDP passed validate
and the rule never fired.

The index now reads each contract through the loader (load_contract), so the
fragments are in and the loader's input protections apply. A contract that
does not compose contributes nothing and is logged at DEBUG, as an
unparseable file always was: the contract being validated reports its own
errors, and one broken neighbour does not stop the walk. A metadata block
that is not a mapping no longer aborts the whole walk either; that product
is indexed with no type.

tests/test_composition_workspace_scan.py proved that each file is read once
by counting calls to this module's own load_yaml_safe. The read happens in
the loader now, so it counts calls to load_contract instead; what it pins is
unchanged.
…consumes are in a fragment

When an upstream cursor rewinds, mark_downstream_dirty flags every product
whose consumes[] names it. find_downstream_products read each *.fluid.yaml
raw, so a product whose consumes[] is a $ref to a fragment was never found
and got no replay-pending marker, with nothing said.

The walk reads each contract through the loader, fragments in. A contract
that does not compose is skipped with a DEBUG line, as an unparseable one
was.
A file holding untrusted YAML or JSON was read whole with Path.read_text
before any cap applied: a sparse 8 GiB file cost about 16 GiB, and a FIFO
or a device standing in for a file blocked or read without end. And the
ref root check, Path.is_relative_to, builds every parent of the path it
tests, so a composition paid for that on every ref.

safe_yaml.read_bounded_file reads a regular file only, checked before it is
opened and again on the file opened without blocking, and never reads past
the cap, sizing its read by the file; read_bounded_text returns its text as
Path.read_text would. ref_confinement.is_within is is_relative_to as a
string comparison of resolved paths, and confine_ref uses it. Nothing calls
the readers yet; the loader and the resolver move onto them next.
…fuse a pointer merged into an object

What was wrong, by scenario finding, and what is true now:

- B14-hybrid, B14-scalar, C20-hybrid, P5-06: a '#/' pointer is a dict until
  the last pass, so a merge took it for data. Beside a file $ref whose target
  held an object at that key, or as an overlay value over an object of the
  base, its $ref landed beside the object's keys, which then beat what it
  named; the overlay's site was lost too. Now it is refused, naming the file
  that wrote it, its pointer there, the place and the remedy (write the keys
  there, or name a file, which is read before the merge). Where no object
  stands it replaces, as before. Over a bare pointer it replaces, and the
  pointer pass refuses it if that one named an object, so an overlay can
  still swap one pointer to a string for another.
- E11-time, DX-04: every ref re-read and re-parsed its target, so a fan-in
  bomb inside the depth limit took about 70 s. A composition now parses each
  file once (every later ref walks its own copy, still charged), and resolves
  at most MAX_REF_RESOLUTIONS = 10,000 refs, file refs and pointers alike; the
  19-level bomb ends at that cap in about 1.5 s.
- S2-F05 (SG-34), E10-msg: fragments nest deeper than a file may, and past
  about 330 levels bundle's YAML writer died while validate passed. The
  composed contract is held to MAX_COMPOSED_DEPTH = 200 levels at compose
  time, pointer copies included, so both commands agree. A RecursionError
  while a fragment is parsed is reported as nesting depth, not as a parse
  failure of a healthy file.
- D-38, D-37, F-overlay-fifo-hang, DX-02: the root contract, overlays and
  JSON or YAML fragments were read whole before the 5 MiB cap, and an
  overlay that was a FIFO blocked every --env run. Every file the composition
  reads is now a regular file read with the bound (util.safe_yaml).
- DX-01, F31 (SG-15), D-34 (SG-14): an overlay reached through a symlink
  was read from outside the ref root, and a symlinked contract took its
  overlay beside the link. The overlay is looked up beside the resolved
  contract and read only from inside the ref root, symlinks resolved,
  never from a system directory.
- D-24 (SG-21): validate, plan and bundle read the file an --env path named.
  The loader holds a non-empty env to the API's contract_env_invalid rule
  where it looks the overlay up; an empty env is still no env.
- S8-07 (SG-22): the in-memory API forms get the ref root a contract file
  in base_dir gets (FLUID_REF_ROOT, the workspace's ref_root).
- DX-03, P5-04: the deny list refused a fragment deep in the root through
  the CLI's path-depth rule, and named neither file nor pointer. It uses the
  CLI's set of system directories only, and names where the ref was written.
- N23: './x.yaml/' named the file x.yaml; a ref ending as a directory does is
  refused. A18: a JSON root that is not an object came back from raw loads;
  it is refused naming the file. M-16: compile_contract parsed a contract
  twice; it parses it once.

Characterization tests whose pinned behaviour these decisions change are
rewritten to pin the new behaviour; the C-22 pointer bomb now ends at the
resolution cap.
…r compatibility (N24)

The scenario suite pinned './a#b.yaml' reading key 'b.yaml' of file 'a' as a
bug (C-12) in a strict xfail. The maintainer keeps it: contracts 0.24.0
loaded may use 'file.yaml#key', so a file ref's fragment need not start with
'/'. Only a same-document '#key' is refused. The xfail is now a
characterization test of the accepted behaviour.
ContractOrigins found each error's line by scanning the mapping's keys one
by one, so n invalid values in one mapping cost n squared: 40,000 took
38 s, a 5 MiB contract of invalid labels about an hour, with or without any
$ref. A mapping's keys are now indexed once per report (P5-09).

The file is read again for its lines under the rules every file the
composition reads keeps: a regular file, read no further than the cap, so
one replaced by a FIFO since cannot block the report.
…ctly

A text holding U+0085 (NEL) was composed exactly (the loader, the JSON bundle,
the plan of the contract) but written into `fluid bundle` YAML and the tgz's
`contract.resolved.yaml` as a bare NEL, which every YAML reader folds into a
space. The two members of one tgz then disagreed, and `plan <bundle.tgz>`
planned different SQL than `plan <contract>`. It hit a key holding NEL too, and
text refs make such content easy to bring in.

Both writers now use a dumper (`ExactDumper` for the `fluid bundle` file, which
used `yaml.dump`; `ExactSafeDumper` for the canonical member, which used
`yaml.safe_dump`) that writes a string holding NEL, LS (U+2028) or PS (U+2029)
double-quoted with escapes (`\N`, `\L`, `\P`). Every other string, number and
key is written as before, byte for byte.
Split checks its result against what the original composes to. When the
original did not compose in the splitter's environment (a ref that needs
FLUID_REF_ROOT, which was not set), the check was skipped: the command
exited 0 and could leave a moved part whose relative ref no longer
resolved (SP-03).

Now such a contract is refused, dry run included, saying it does not
compose here and why; nothing is written. With the root its refs need, it
splits and is checked as any other. Rebasing a relative ref inside a moved
part (SP-02) stays a follow-up: split refuses that case instead.
Contract fragments: drafts 21-29 of the $ref work (text refs, siblings,
pointers, list roots, one composition with overlays, provenance, the
authoring schema and validate --fragment, docs, split --parts, the
workspace ref_root), the P0/P1 findings of the scenario suite, and the two
security fixes already on main since 0.24.0 (#758, #759).
…is an unknown kind

validate dispatched to the fragment check on any truthy args.fragment, so a
caller handing run() an args object without the attribute set (a mock, a
plugin's namespace) took the fragment path with a non-text kind; and
--fragment '' was ignored, validating the file as a whole contract. The
fragment check now runs for a kind given as text, and an empty kind is
refused as an unknown kind (half of K14).

schema_authoring reads the canonical schema with encoding='utf-8' spelled
as the keyword the text-I/O guard looks for.
…s of their own

run() had grown to cyclomatic complexity 16 with the compose check of
SP-03; _composed and _print_dry_run take those branches. No behaviour
change: the split suites pass unchanged.
The confinement tests plant a fake secret marker and a dummy OpenSSH key
to prove a ref never composes or echoes them, and the authoring-schema
test pins the sha256 of each canonical schema 0.24.0 shipped. None is a
credential; each line carries detect-secrets' allowlist pragma so the CI
secret scan on changed files passes.
…leaf, not cli

The composition screened every ref target against the CLI's deny set by
importing fluid_build.cli.security. Once apply's replay walk composed the
contracts it reads, build_runners reached cli through loader ->
contract_refs -> cli.security, and the import-linter contract
'build_runners must not depend on cli' broke (the CI import-hygiene job).

The set (build_forbidden_paths, FORBIDDEN_PATHS) moves verbatim to the
stdlib-only leaf fluid_build._path_policy; cli.security re-exports both
names, so its checks and callers are unchanged, and the composition reads
the same set from the leaf. loader now reaches no cli module at all. The
leaf joins the tier-0 contract.
… not a name under it

load_contract_from_text / load_contract_from_dict with base_dir composed
the document under the root a contract file in base_dir would get, found by
resolving base_dir/contract.fluid.yaml. That file is never read, but
resolve() follows a symlink at that name, so whoever controls base_dir's
contents chose the root: an upload holding 'contract.fluid.yaml -> .' (a
link tarfile's 'data' filter keeps) widened it to the parent directory and
composed another tenant's files; '-> /' widened it to the whole host bar the
system directories. FLUID_IGNORE_WORKSPACE_REF_ROOT did not stop it. Found
by the security-review gate; introduced on this branch.

The root is now taken from base_dir itself, resolved; a name under it moves
nothing.
…k or from above the repository

validate --fragment chose the fragment's ref root from the nearest
contract.fluid.* above it, accepting a symlink and walking up to /. A
repository shipping 'contract.fluid.yaml -> ~/.profile' moved the root to
the home directory, and a fragment's ref then composed ~/.docker/config.json
and printed it in a schema error; the CLI already refuses a symlinked
contract path for this reason. Without a link, an unrelated contract high up
(~/contract.fluid.yaml) widened the root the same way. Found by the
security-review gate; introduced on this branch.

The lookup skips symlinks and stops at the fragment's repository or
workspace (.git or fluid.workspace.yaml), and a fragment outside the root
its contract gives is refused.
fas89 added 9 commits October 28, 2026 13:25
…used, naming the overlay

The mirror of the round-2 fix: an overlay value written as a #/ pointer to
a list of objects, over the base's builds or an expose's columns, replaced
the base list where the same value inline (or as a file ref) merges by id
or by position, so plan --env prod dropped a build and overlaid columns lost
their keys with exit 0. Found by the defect-scan gate, round 3.

It is refused, as a pointer merged into an object is. A refusal raised while
merging an overlay now names the overlay file when its message does not.
…efore reading

Round 2 checked every file a composition read against --readable-paths, but
only after the composition succeeded: a composition that failed on a file
outside the readable paths raised its error first, quoting that file's key
names (a pointer miss) or a line of it (a YAML error), and mcp 1.x, which
the pin allows, hands that text to the client. Found by the defect-scan and
world-class-inspection gates, round 3.

Under an active policy the contract is now composed under a ref root capped
to the readable path that holds it, when the root it would get is wider: a
ref outside fails as an escape before the file is opened. The post-check
stays.
…s split as data

Found by the defect-scan gate, round 3: the data split (a JSON root, a root
with aliases) wrote text holding U+0085, U+2028 or U+2029 bare, which YAML
reads back as a space, so split refused valid contracts; it now writes with
the bundle's ExactDumper. A file with bare CR line ends gave the text
splice line arithmetic it does not handle and an unparsable root; it is now
split as data, with the notice that its comments were not kept.
…ut parsing the contract

_readable_ref_root parsed the contract itself only to learn whether it
names another file, which the raw-reader guard
(test_g20_no_unlisted_module_reads_a_contract_itself) rightly flags. It
now asks for the root a contract that names one would get
(reads_outside): one that names none reads nothing past its own directory
anyway.
…f objects is refused

The last bypass of the round-3 refusal: with the base list itself written as
a bare #/ pointer, an overlay pointer replaced it, and the pointer pass
checked only whether the replaced pointer named an object, so the overlay's
list replaced the base's whole (plan --env prod dropped a build, exit 0)
where, inline, it merges by id. Found by the defect-scan and
world-class-inspection gates, round 4.

A replacement an overlay made is now refused when the replaced pointer
names a non-empty list of objects too; keys beside a file $ref stay a
merge patch, where a list replaces. The refusal says 'list of objects' and
'the entries' for that case.
- mcp: a --readable-paths entry that names a file is not a ref root; the cap
  uses the deepest readable directory holding the contract, else its own
  directory (a regression of the round-3 cap: 'ref_root=... is not a
  directory').
- split: NEL, LS and PS end a line for YAML too, so a file using them as
  line breaks is split as data, like a bare CR.
- validate --fragment: a nearest contract whose fluidVersion is a $ref gives
  no version instead of crashing (cli_unhandled_exception).
- the parse cache keys a file by name when its file system reports no
  inode (st_ino 0).

The subprocess hang guards of tests/fragments wait 60 s, not 6: a cold
fluid validate under a loaded runner took longer (round-4 gate finding).
@github-actions github-actions Bot added cli Changes to the CLI surface or implementation docs Documentation changes tests Test coverage or test infrastructure changes needs-docs Pull request needs a linked docs update or justification labels Oct 28, 2026
@github-actions

Copy link
Copy Markdown

📄 Documentation Reminder

This PR appears to be missing a documentation reference. Our docs live in a separate repo.

Please update the PR description with one of:

  • Link a docs PR — check the "Docs PR linked" box and paste the URL
  • Mark as no docs needed — check "No docs needed" with a justification
  • Acknowledge docs TODO — check "Docs TODO" and create the docs PR before merge

See the Contributing Guide for details.

Comment thread fluid_build/contract_refs.py Fixed
Comment thread fluid_build/loader.py Fixed
Comment thread fluid_build/loader.py Fixed
Comment thread fluid_build/loader.py Fixed
Comment thread fluid_build/loader.py Fixed
Comment thread tests/fragments/test_encodings.py Fixed
Comment thread fluid_build/schema_manager.py Fixed
Comment thread fluid_build/cli/security.py Fixed
contract_refs' unused LOG, schema_manager's unused Sequence import,
cli.security's unused platform import and _build_forbidden_paths
re-export (the set lives in fluid_build._path_policy), two unused test
imports and a needless lambda. The loader's resolver aliases
(_MAX_REF_DEPTH, _is_ref_node, _parse_ref, _resolve_pointer) stay for the
callers 0.24.0 had, and are listed in __all__ as the export they are; the
contract API reads parse_ref from contract_refs itself.
Comment thread fluid_build/cli/validate.py Fixed
Comment thread tests/fragments/test_docs.py Fixed
fas89 added 2 commits October 28, 2026 16:15
…eview's next findings

The fragment scenario suite (tests/fragments, ~1,900 tests) made the
Coverage & Audit job run past its 30-minute timeout (it ran 19 on main) and
brought the 3.10 test leg to 24 of its 25 minutes. The test legs get 35 and
the coverage job 45: still minutes, not GitHub's 6-hour default.

The code-quality review's next batch: unused test imports, two needless
lambdas, and validate --fragment importing its own module where it can
import SchemaVersion and ValidationResult directly.
@github-actions github-actions Bot added the ci Continuous integration and automation changes label Oct 28, 2026
Comment thread tests/fragments/test_encodings.py Fixed
The code-quality review flags a module imported with both `import` and
`from ... import` in one file. Eleven fragment test modules imported the
package only to read `fluid_build.__file__` for their "this checkout runs"
check, beside `from fluid_build import loader`; they now read the path
from the module they already import. Two MCP tests import the `mcp`
package the way their file's other imports do.
@fas89
fas89 merged commit 50f3562 into main Oct 28, 2026
38 checks passed
fas89 added a commit that referenced this pull request Oct 30, 2026
…n parallel (#770)

* ci: coverage measures on Python 3.14, where branch coverage runs on sys.monitoring

The Coverage & Audit job went from ~15 to 37-41 minutes after the
fragment suite landed (#760): with `branch = true`, coverage.py on 3.12
can only use the C tracer, and that tracer took tests/fragments/test_split
(390 in-process tests) from 6.7 to 31.3 minutes in CI. coverage.py's
sys.monitoring core ("sysmon") measures branches only on 3.14+, where it
is the default (https://coverage.readthedocs.io/en/latest/config.html).

Measured locally on that module: 3.12 226s plain, 546s with coverage;
3.14 217s plain, 220s with coverage. The job now runs on 3.14 with
COVERAGE_CORE=sysmon set (a fallback then at least logs no-sysmon), and
its timeout drops from 45 to 30, under the ~40 min a fallback would
take. Gate margins on main are 5 to 49 points (core 82/70, V1 85/80,
local 80/50, cloud 69/20). Nothing outside ci.yml uses the job or
artifact name; neither is a required check.

* ci(iac-tests): Stage 1's tofu lanes run as parallel jobs, and its unit step under xdist

Stage 1 ran its six tofu lanes one after another in a single job, about
27 minutes on main for a month: the job took the sum of them. Each lane
runs its own files against moto or an in-process stand-in and asserts
its own junit, so each is now a matrix job, and Stage 1 takes the
longest lane plus setup. A lane shares one TF_PLUGIN_CACHE_DIR across its
modules (the tests honour an external one), one provider download per
lane instead of per module; a lane is one pytest process, so nothing
writes that cache concurrently.

The unit step ran ~7.4k tests in one process (five minutes); it now runs
with `-n auto --dist loadscope`, as ci.yml's test matrix runs the same
tests on every PR, under the same MALLOC_ARENA_MAX cap. The emulator
extra stays out of that job, so it still cannot change what the unit
step runs. Check names: "Stage 1 — unit + tofu validate" stays; the
lanes report as "Stage 1 — <lane>". None is a required check.

* ci: the secrets baseline follows the test credential iac-tests.yml moved

Splitting Stage 1 into lanes moved `AWS_SECRET_ACCESS_KEY: test` from
line 258 to 318, so the changed-files detect-secrets step failed on the
stale line number. Regenerated with detect-secrets 1.5.0, the CI pin.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci Continuous integration and automation changes cli Changes to the CLI surface or implementation docs Documentation changes needs-docs Pull request needs a linked docs update or justification tests Test coverage or test infrastructure changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant