Skip to content
1 change: 1 addition & 0 deletions api/src/shared/database/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ def configure_polymorphic_mappers():
Feed.redirectingids, # redirectingid_source_id_fkey
Feed.redirectingids_, # redirectingid_target_id_fkey
Feed.feed_license_changes,
Feed.sealcriteria, # sealcriterion_feed_id_fkey
],
Gtfsfeed: [Gtfsfeed.gtfs_dataset_changelogs],
Gbfsfeed: [
Expand Down
51 changes: 51 additions & 0 deletions api/tests/integration/cascade_delete/test_cascade_delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
Gtfsfeed,
Geopolygon,
FeedLicenseChange,
Sealcriterion,
)

from sqlalchemy import text
Expand Down Expand Up @@ -207,6 +208,56 @@ def test_delete_feed_cascadeto_feed_license_changes(test_database):
)


def test_delete_feed_cascadeto_sealcriterion(test_database):
"""Deleting a feed removes its per-criterion seal state (issue #1760).

This exercises the `Feed.sealcriteria` entry in `cascade_entities`: without it,
SQLAlchemy would try to NULL `sealcriterion.feed_id`, which is NOT NULL and part of the
composite primary key, instead of letting ON DELETE CASCADE do the work.
"""

with test_database.start_db_session() as session:
feed = Feed(id="f1")
seal_criterion = Sealcriterion(feed_id="f1", criterion="official")
session.add_all([feed, seal_criterion])
session.commit()

delete_and_assert(
session,
[
"SELECT COUNT(*) FROM feed where id = 'f1'",
"SELECT COUNT(*) FROM sealcriterion where feed_id = 'f1'",
],
feed,
)


def test_delete_feed_cascadeto_feedreliabilityseal(test_database):
"""Deleting a feed removes its overall seal row (issue #1760).

The row is inserted with raw SQL rather than the ORM: `feedreliabilityseal.feed_id` is
both its primary key and a foreign key to `feed.id`, so sqlacodegen maps the table as a
joined-table subclass of Feed. Adding a `Feedreliabilityseal()` instance would therefore
try to insert a second feed row rather than a child row.
"""

with test_database.start_db_session() as session:
feed = Feed(id="f1")
session.add(feed)
session.commit()
session.execute(text("INSERT INTO feedreliabilityseal (feed_id) VALUES ('f1')"))
session.commit()

delete_and_assert(
session,
[
"SELECT COUNT(*) FROM feed where id = 'f1'",
"SELECT COUNT(*) FROM feedreliabilityseal where feed_id = 'f1'",
],
feed,
)


def test_delete_gbfsfeed_cascadeto_gbfsversion_cascadeto_gbfsendpoint_cascadeto_gbfsendpointhttpaccesslog(
test_database,
):
Expand Down
71 changes: 71 additions & 0 deletions functions-python/tasks_executor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,3 +406,74 @@ Only **comparable** datasets are considered: a dataset must have a `downloaded_a
| `pairs_dispatched` | Pairs dispatched (or, in `dry_run`, that would be dispatched) |
| `dispatched` | (dry-run only) list of `{feed_stable_id, base_dataset_stable_id, new_dataset_stable_id}` |


### update_seal_of_reliability

Evaluates the implemented Seal of Reliability criteria (issue #1761) for every eligible GTFS
feed and updates the `sealcriterion` and `feedreliabilityseal` tables. Reads the source
tables and never modifies them.

`seal_criterion_name` in the database declares all six criteria, so adding an evaluator
needs no schema change. The criteria still to be implemented are tracked by #1784 and #1782.

Eligible feeds are GTFS, `operational_status = published`, and `status NOT IN (deprecated,
development)`. `inactive` and `future` feeds are deliberately included: skipping a feed
freezes its stored rows rather than making it neutral.

```json
{
"task": "update_seal_of_reliability",
"payload": {
"dry_run": true,
"stable_feed_ids": ["mdb-1210"]
}
}
```

| Parameter | Type | Default | Description |
|---|---|---|---|
| `dry_run` | bool | `true` | Evaluate every feed and return the report without writing anything |
| `stable_feed_ids` | list[str] \| null | `null` | Evaluate only these feeds. Ids that are unknown or not eligible are skipped with a logged warning naming them; it raises only when *none* of them can be evaluated. When set, `feeds` covers every one of those feeds instead of only the ones that moved |
| `limit` | int \| null | `null` | Cap the number of feeds evaluated |
| `criteria` | list[str] \| null | `null` | Evaluate only these criteria. Naming a criterion that has no evaluator yet raises. A subset of the implemented criteria skips the `has_seal` roll-up, since the ones not evaluated cannot be judged |
| `batch_size` | int | `200` | Feeds loaded per query batch. Every eligible feed is still evaluated — this only sizes the queries |
| `max_reported_feeds` | int | `50` | Cap on the `feeds` list in the response. Everything is still evaluated and written; `feeds_omitted` says how many entries were left out |
| `now` | str \| null | `null` | ISO timestamp to evaluate against, for replays and backfills. Defaults to the current UTC time |

**Response fields**:

| Field | Description |
|---|---|
| `total_feeds` | Feeds evaluated |
| `criteria` | The criteria evaluated in this run |
| `partial_run` | True when `criteria` was a subset, meaning `has_seal` was not recalculated |
| `criterion_rows_written` | `sealcriterion` rows inserted or updated (`0` on a dry run) |
| `not_evaluable` | Criterion evaluations skipped because an input was missing |
| `seals_before_run` | Feeds that held the seal before this run |
| `seals_after_run` | Feeds holding it afterwards — on a dry run, what *would* be stored |
| `seals_granted` / `seals_revoked` | Transitions in this run. `before + granted - revoked == after` |
| `granted_stable_ids` / `revoked_stable_ids` | The feeds behind `seals_granted` / `seals_revoked` — the two transitions written to `feedreliabilityseal` in this run |
| `first_evaluations` | Criteria evaluated for the first time (no stored row yet) |
| `feeds` | One entry per reported feed, capped at `max_reported_feeds`: `stable_id`, its `feedreliabilityseal` state (`had_seal`, `has_seal`), and a nested `criteria` list holding every criterion of that feed with `observed_pass`, `confirmed_pass`, `previously_confirmed_pass`, `on_probation` and `reason`. A feed is reported when it was named in `stable_feed_ids`, when one of its criteria moved, or when its seal changed — so a quiet nightly run returns an empty list |
| `feeds_omitted` | Feeds left out of `feeds` by the cap. `sealcriterion` and `feedreliabilityseal` hold everything regardless |

#### Running it locally

Start Postgres and the function, then post to it:

```shell
docker compose --env-file ./config/.env.local up -d --force-recreate
scripts/function-python-run.sh --function_name tasks_executor --no_install_venv
```

```shell
curl -s -X POST http://localhost:8080 -H "Content-Type: application/json" \
-d '{"task":"update_seal_of_reliability","payload":{"dry_run":true,"stable_feed_ids":["mdb-1210"]}}' \
| python3 -m json.tool
```

`Accept: text/csv` returns a single summary row (the top-level report fields), not one row
per feed — the converter flattens the returned dict, and `feeds` lands in it as a single
stringified cell. Use the JSON response for per-feed and per-criterion detail.

Nothing about this task needs GCP credentials — only `FEEDS_DATABASE_URL`.
17 changes: 17 additions & 0 deletions functions-python/tasks_executor/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@
notifications_dispatch_monitor_handler,
)
from tasks.changelog.backfill_changelog import backfill_changelog_handler
from tasks.seal_of_reliability.update_seal_of_reliability import (
update_seal_of_reliability_handler,
)

init_logger()
LIST_COMMAND: Final[str] = "list"
Expand Down Expand Up @@ -255,6 +258,20 @@
),
"handler": backfill_changelog_handler,
},
"update_seal_of_reliability": {
"description": (
"Evaluates the implemented Seal of Reliability criteria for every eligible "
"GTFS feed and updates sealcriterion and feedreliabilityseal. "
"Reads the source tables and never modifies them. "
"Parameters: dry_run (default true), stable_feed_ids (default null; when set, "
"`feeds` covers every one of those feeds), limit (default null), "
"criteria (default null "
"meaning every implemented criterion; a partial set skips the has_seal "
"roll-up), batch_size (default 200), now (ISO timestamp, default current "
"UTC time)."
),
"handler": update_seal_of_reliability_handler,
},
}


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
#
# MobilityData 2026
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""Feed eligibility query and bulk loading of everything the evaluators need.

The evaluators are pure functions over a `FeedSealContext`, so every DB read for a batch of
feeds happens here, in a fixed number of queries regardless of batch size.

Only Official is implemented, so the context currently carries just the feed row. Each new
criterion adds the fields it needs here plus one bulk query to populate them: the latest
dataset for Compliant and Fresh, the day's availability rows for Available, the full
dataset coverage history for Fresh continuous coverage.
"""

from dataclasses import dataclass
from datetime import datetime
from typing import Dict, Optional, Sequence

from sqlalchemy.orm import Session

from shared.database_gen.sqlacodegen_models import Feed, Gtfsfeed


@dataclass
class FeedSealContext:
"""Everything the evaluators need for one feed.

Built by `build_contexts`. Evaluators read from this and never query.
"""

feed_id: str
# The evaluation timestamp. Passed in rather than read from the clock so evaluators
# stay pure and a run can be replayed for any point in time.
now: datetime
stable_id: Optional[str] = None

# Feed-level flags
official: Optional[bool] = None


def get_seal_feeds_query(
db_session: Session, stable_feed_ids: Optional[Sequence[str]] = None
):
"""Return a query for the feeds the seal applies to.

Eligibility is defined here and nowhere else, so a full run and a one-feed run exercise
the same predicate. `inactive` and `future` feeds are deliberately included: skipping a
feed does not make it neutral, it freezes its stored rows, and once Fresh (future
coverage) exists an inactive feed should fail it rather than keep displaying a seal.
"""
query = db_session.query(Gtfsfeed).filter(
Feed.data_type == "gtfs",
Feed.status.notin_(["deprecated", "development"]),
Feed.operational_status == "published",
)
if stable_feed_ids is not None:
query = query.filter(Feed.stable_id.in_(list(stable_feed_ids)))
return query


def build_contexts(
db_session: Session, feeds: Sequence[Gtfsfeed], now: datetime
) -> Dict[str, FeedSealContext]:
"""Load everything the evaluators need for `feeds`, in a fixed number of queries.

Args:
db_session: SQLAlchemy session. Unused while Official is the only criterion, since
everything it needs is already on the feed row, but kept in the signature
because every further criterion needs it.
feeds: The batch of feeds to load, from `get_seal_feeds_query`.
now: The evaluation timestamp.

Returns:
feed_id -> FeedSealContext.

How to add a criterion's data. Two kinds:

1. Already on the selected feed row (`official`, `created_at`, `seasonal`,
`is_producer_url_unstable`). Add the field to FeedSealContext and read it off `feed`
below. No query, no cost.

2. Needs its own query. Add the field, then a module-level `_load_*` helper that takes
the whole batch and returns a dict keyed by feed_id, and call it once here. Keeping
the query per batch rather than per feed is what holds the query count proportional
to the number of criteria instead of the number of feeds. For example, Available
(issue #1784) would add:

def _load_availability_today(db_session, feed_ids, day_start) -> Dict[str, bool]:
'''feed_id -> whether any availability check succeeded since day_start.
Feeds absent from the result had no check at all, which the criterion reads
as "not evaluable" rather than "failing".'''

called once as `availability = _load_availability_today(...)` and consumed per feed
as `availability_success_today=availability.get(feed.id, False)`.
"""
return {
feed.id: FeedSealContext(
feed_id=feed.id,
now=now,
stable_id=feed.stable_id,
official=feed.official,
)
for feed in feeds
}


def batched(items: Sequence, size: int):
"""Yield successive slices of `items` of at most `size` elements."""
for start in range(0, len(items), size):
yield items[start : start + size]
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#
# MobilityData 2026
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""Seal of Reliability policy values.

Everything here is the published definition of the seal, so it lives in code rather than
in DB config: changing any of these values changes which feeds qualify, and that should
go through code review, tests and a deploy.

Each criterion's grace period belongs with the criterion, as a class attribute on its
evaluator. Only Official is implemented so far and it has none.
"""

from datetime import timedelta
from enum import Enum
from typing import Final


class SealCriterionName(str, Enum):
"""The six seal criteria. Values match the seal_criterion_name DB enum.

All six are listed even though only Official has an evaluator (see #1784 and #1782),
so that the enum stays a faithful mirror of the database type.
"""

OFFICIAL = "official"
STABLE = "stable"
AVAILABLE = "available"
COMPLIANT = "compliant"
FRESH_COVERAGE = "fresh_coverage"
FRESH_CONTINUOUS = "fresh_continuous"


# A criterion that recovers from a confirmed failure is put on probation: it must then go
# this long with no observed failure before it can contribute to the seal again. It is the
# default for new evaluators; Official is exempt because it is a point-in-time state check
# (see OfficialEvaluator).
#
# Probation is opened only by a recovery. A first evaluation that passes is not a recovery,
# so a feed that has never had a confirmed failure never serves probation at all and can
# hold the seal from its very first evaluation.
PROBATION_PERIOD: Final[timedelta] = timedelta(days=180)
Loading
Loading